Compare commits
393 Commits
v0.1.0
...
feature/exapp
| Author | SHA1 | Date | |
|---|---|---|---|
| b047fce290 | |||
| fad2cd8dcb | |||
| 06042357f8 | |||
| 5bdf840098 | |||
| 9711d1d161 | |||
| 2d802483e5 | |||
| b3cd2ace34 | |||
| 2cd91ceee7 | |||
| 84106a059e | |||
| c1c5a61952 | |||
| e7c4eb0842 | |||
| 2f60dec90d | |||
| 59633017b0 | |||
| 6fa59621bf | |||
| c2284298ce | |||
| 7498b501eb | |||
| 652c58d1fb | |||
| e7a5caa0d6 | |||
| d2d413afcd | |||
| 3c3df0d3a5 | |||
| c59bcca053 | |||
| 18973e061a | |||
| 167053578d | |||
| 2633b63a04 | |||
| 5d4902a73e | |||
| b55b9640c6 | |||
| b1eb4d2497 | |||
| 6c580fec01 | |||
| bbd8d1cf63 | |||
| d01c6ee0d0 | |||
| d48b93e8fc | |||
| 7b663c5476 | |||
| 73257e749f | |||
| d66faa9533 | |||
| 58fd0283ea | |||
| 3feac952da | |||
| 6a2ed9815a | |||
| c1c01196a4 | |||
| 930bb280fe | |||
| e36d020f6b | |||
| c13240819a | |||
| c2c2a71c4b | |||
| 21f6164e07 | |||
| 420fa9173d | |||
| da4d48c493 | |||
| 404abe8695 | |||
| 28dd24510d | |||
| f72bb7e996 | |||
| 9c0a0e9bf3 | |||
| 78b96177bd | |||
| 70b0754a19 | |||
| f034012101 | |||
| 7c4c0284f3 | |||
| 892340fb66 | |||
| f79b957644 | |||
| ef1fb9e9aa | |||
| d712b5487c | |||
| 892a8d2d23 | |||
| daeb95f3c3 | |||
| 36d44d1781 | |||
| 949fb7124b | |||
| 6c4f071d2b | |||
| 53b11f7fbb | |||
| 336bc45637 | |||
| 6c587bb265 | |||
| 6b1f5c12c8 | |||
| f8dc1f060b | |||
| 4cf5f2a95a | |||
| 1cc65f0160 | |||
| 9b00530e8e | |||
| 938376425b | |||
| 0484167a22 | |||
| 84ad1958af | |||
| fa002296ff | |||
| 464ff2c8b2 | |||
| 0804ff8d17 | |||
| 4f7023a16e | |||
| 8f6656c546 | |||
| 741c58d9a3 | |||
| e7b79d0316 | |||
| 0e4cc8e56f | |||
| 16da7a9a76 | |||
| 520e515f2b | |||
| fd6ce7b294 | |||
| 8063059f5f | |||
| 20c5046b20 | |||
| 68126640d8 | |||
| af617e3869 | |||
| 04e5f7beca | |||
| 6ed1efab24 | |||
| cffa002364 | |||
| 951a7095b2 | |||
| ee31f33038 | |||
| 0fdbfae198 | |||
| 315f918d88 | |||
| 96a8491a4c | |||
| 0a311766f2 | |||
| d28c249f8d | |||
| ab6cac8799 | |||
| 7127b9953f | |||
| 49c9af3c76 | |||
| 823151f42e | |||
| 2bbd56e1cd | |||
| 8a36a120a7 | |||
| 9df8cc937d | |||
| 325dcdf654 | |||
| 945eb1eb4e | |||
| 088343d003 | |||
| 94d553985f | |||
| 982dbd18ca | |||
| 054fa38e3a | |||
| 3836534205 | |||
| f852a18b12 | |||
| 0450c5cc52 | |||
| f48fd0be60 | |||
| ee29194bc9 | |||
| fc32fa2852 | |||
| b7d6548741 | |||
| a9ffd49815 | |||
| 538f861414 | |||
| b784651f7f | |||
| 6f0baf5fca | |||
| 664254ed95 | |||
| b976494ca2 | |||
| 061f667e00 | |||
| 3319c35798 | |||
| 52c9293c37 | |||
| af6863a764 | |||
| 77181f7c6f | |||
| 61f3beac01 | |||
| 49aaf24363 | |||
| 4edd31ee28 | |||
| 9ae2a0fc6f | |||
| 8386644dfd | |||
| 1dfdad5fad | |||
| 72cb62a101 | |||
| 21fc55320b | |||
| ad3e288203 | |||
| 0a97357a9c | |||
| 70f01bf40a | |||
| 37b1057d2a | |||
| ad95140416 | |||
| 73fb56f73d | |||
| 9cc5300aa8 | |||
| be466abc0c | |||
| 8956945e9d | |||
| a9f3e1b00d | |||
| a5e3f949c2 | |||
| acc505aa01 | |||
| 69fccb496a | |||
| 6bdbb6ea6c | |||
| 0b8a3aa646 | |||
| ed270bb926 | |||
| 56e5298cce | |||
| 2bcfd3d7ee | |||
| 75235d6013 | |||
| 19631838bb | |||
| 3cab343416 | |||
| 1a253af1c0 | |||
| b81fe6dfa0 | |||
| 2a5b12343c | |||
| 66d306708d | |||
| e7598a5467 | |||
| fb6aa954b6 | |||
| 02ad283a01 | |||
| 13ba9ef2e6 | |||
| 4767e88d2b | |||
| e38d0a8bdc | |||
| 1dca929983 | |||
| 6a2bd4d274 | |||
| c91001d7e1 | |||
| 83748a27da | |||
| 3ddeeab67f | |||
| a2c78ee1ef | |||
| 1e19061ee0 | |||
| 2e078498b1 | |||
| 7291c930c4 | |||
| b8191c134a | |||
| 09061d9e4f | |||
| 2d3cb85fb2 | |||
| 3ad07d05dd | |||
| 50c1215676 | |||
| bf5879d408 | |||
| 442e82e994 | |||
| 9e96999f02 | |||
| e983693534 | |||
| b8a14a2229 | |||
| 508f83dfad | |||
| ce8d5f92b1 | |||
| ca32ff39b8 | |||
| 9da53e51f0 | |||
| 2cbac7c4be | |||
| d2394465d7 | |||
| c2615ac24d | |||
| 62e21f1f94 | |||
| 9bd95a8b17 | |||
| bfd2eed97b | |||
| 8a0b964add | |||
| 59bab51090 | |||
| 12fa550b60 | |||
| 85cdf75a5b | |||
| 0ee2b5b034 | |||
| 0c4d140bb9 | |||
| f515d74a4d | |||
| 79835b3439 | |||
| d518b76878 | |||
| 5179db40db | |||
| 9cbeecae64 | |||
| c5af81c94f | |||
| ae966710a9 | |||
| 9b14135dd3 | |||
| 6f92cd8157 | |||
| 6545f8165f | |||
| 4a742442fb | |||
| f84144fcaa | |||
| e09f373f84 | |||
| e50be7db07 | |||
| f03ab4ef55 | |||
| 3d26c6c145 | |||
| a4b0c84f79 | |||
| e67e7c4246 | |||
| e0c4cc5d77 | |||
| b43ffad708 | |||
| cab7a59d2b | |||
| ca5bbb783a | |||
| d47e2bb8f0 | |||
| a1c186aa95 | |||
| 57440f845f | |||
| a57c12591a | |||
| 5b512f83bd | |||
| 4a2fd67e51 | |||
| da3a0049a0 | |||
| bb53ba6275 | |||
| 7a6c7c6efa | |||
| 266d2dac8d | |||
| d64c6e112e | |||
| 167517b95d | |||
| 33aa778713 | |||
| 251c9aaae6 | |||
| ded48acd31 | |||
| 0dacd84cc2 | |||
| c0782dc69e | |||
| 4a8f9f7f7e | |||
| db9f2cad43 | |||
| d52860c86d | |||
| 4992f700c6 | |||
| cc2777210b | |||
| ad1320319b | |||
| 9d9f1e1eaa | |||
| 7b3b624403 | |||
| 5c908bf8d2 | |||
| fe16f4db54 | |||
| 7b10296058 | |||
| e6890ab24d | |||
| cf49866a87 | |||
| d8e7d0b465 | |||
| c336c5d2a2 | |||
| 45c0622459 | |||
| 7dfbe9dd62 | |||
| 3d5da56d83 | |||
| 2b1dbfef39 | |||
| 2e016080fd | |||
| e0a966b4a6 | |||
| 07a8b6e704 | |||
| 659da9a770 | |||
| 18f8b73982 | |||
| 2bc0988e8d | |||
| 74235ed8bb | |||
| 89a9af7c25 | |||
| d247a07643 | |||
| 794d4184d2 | |||
| cc17b28eab | |||
| 5626f6fd6f | |||
| 79a466d16c | |||
| 6aa06b4c9d | |||
| c993872ab5 | |||
| e69819a49b | |||
| 49868d2bb5 | |||
| 33c8623d5c | |||
| 150e656a36 | |||
| 2708d708b0 | |||
| c1e3a6aeaa | |||
| 5ee9435741 | |||
| 110df3d7b9 | |||
| fd61c2de56 | |||
| ee32a1bfe8 | |||
| c918284927 | |||
| 98586a3684 | |||
| 7e02527531 | |||
| 60af7ae255 | |||
| 2437d5fb12 | |||
| 615d27a9c9 | |||
| 088f6aec3f | |||
| 80c55d5bdc | |||
| 63ccc9dc6c | |||
| ec81f932ee | |||
| 88e6e865f6 | |||
| e6a5e235ea | |||
| 85a5014479 | |||
| 14da0f2451 | |||
| dfa0d50497 | |||
| 266c8bf90d | |||
| 2b5bb1cc81 | |||
| 847a69e2ba | |||
| 186d2c1d94 | |||
| 96d5789200 | |||
| b332c54330 | |||
| 9a05b171ae | |||
| e93eb9d302 | |||
| 5af7c25dab | |||
| a0b9482915 | |||
| 85b9a14fc6 | |||
| e53f4dc2dc | |||
| 8147f237cd | |||
| d4966fc925 | |||
| f173e957f3 | |||
| 78fd4eb54c | |||
| 93092a94cc | |||
| 914aef2861 | |||
| fab0f3ef05 | |||
| 0e6ff3bdda | |||
| 37f031d13e | |||
| 02e05dc8d0 | |||
| 21019c6cff | |||
| 050d236312 | |||
| 4b57d4e5c9 | |||
| a0dddbe7df | |||
| d19b1ad680 | |||
| db34473218 | |||
| 20ebd7bbcb | |||
| d48e151e95 | |||
| 892e0b4c01 | |||
| dd7eab05db | |||
| 23735aad85 | |||
| f6d4695180 | |||
| 0a138caff4 | |||
| afb08a7533 | |||
| cbed6f2b41 | |||
| 463d90a778 | |||
| 8ee2f684ec | |||
| 6288e50766 | |||
| 17b539dc21 | |||
| cf20948999 | |||
| 7a7d627efc | |||
| effa1890aa | |||
| 8e1f265e3f | |||
| 7f39b9e07d | |||
| 6ca9efbb8a | |||
| eff0f441cb | |||
| 588cb1cb70 | |||
| b85351cb24 | |||
| 089bcf92ba | |||
| cf6d2cfed7 | |||
| 5ab01f3459 | |||
| b348ce9ea1 | |||
| ee9474bf06 | |||
| 4bb1e4cf50 | |||
| 02e55a4636 | |||
| e1ecf0cdbf | |||
| a9db4fb0af | |||
| 26a6f154a9 | |||
| 8d8e6d9c99 | |||
| d41076b1a0 | |||
| 65a704869d | |||
| cfd4df971d | |||
| 81c5016e5c | |||
| b1517317fa | |||
| 83d4c33b31 | |||
| 76381c3365 | |||
| a91d6ae9f7 | |||
| 56d8d7b8f0 | |||
| c3e2c28f6b | |||
| b0012d6e4a | |||
| 3a39e525e1 | |||
| c1763ebc6a | |||
| c289646d27 | |||
| c6ce5bd338 | |||
| dea882c2f5 | |||
| e1de793af8 | |||
| 04e4a8e0a8 | |||
| 973caefb74 | |||
| c1e8252be4 | |||
| 47b7d4deed | |||
| 03e6a03767 | |||
| eb9e9a0e94 | |||
| 4b40fcfc46 | |||
| 8bc419228f | |||
| 43971ad0b2 | |||
| ca980ec141 | |||
| 6c0d539588 | |||
| 79d1bc8c74 | |||
| 4e346275eb | |||
| d66c0c7cfa |
@@ -0,0 +1,32 @@
|
||||
name: Bump version
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: "!startsWith(github.event.head_commit.message, 'bump:')"
|
||||
runs-on: ubuntu-latest
|
||||
name: "Bump version and create changelog with commitizen"
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
- name: Create bump and changelog
|
||||
uses: commitizen-tools/commitizen-action@5b0848cd060263e24602d1eba03710e056ef7711 # 0.24.0
|
||||
with:
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,7 +2,7 @@ name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -10,18 +10,16 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
#cbcoutinho/nextcloud-mcp-server
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
@@ -35,18 +33,18 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
+24
-18
@@ -1,19 +1,37 @@
|
||||
|
||||
name: Docker Compose Action
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ruff check
|
||||
|
||||
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@v2.0.1
|
||||
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@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -31,18 +49,6 @@ jobs:
|
||||
done
|
||||
echo "Service is ready (returned 401)."
|
||||
|
||||
- name: Install notes app
|
||||
run: |
|
||||
docker compose exec app php occ app:enable notes
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt update -y && sudo apt install -y pipx
|
||||
pipx install poetry
|
||||
poetry install
|
||||
env:
|
||||
DEBIAN_FRONTEND: "noninteractive"
|
||||
|
||||
# Add subsequent steps here, e.g., running tests
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -50,4 +56,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
poetry run python -m pytest
|
||||
uv run --frozen python -m pytest
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
repos:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.9.0
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- pre-push
|
||||
- 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]
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
## v0.12.1 (2025-09-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **docker**: Provide --host 0.0.0.0 in default docker image
|
||||
|
||||
## v0.12.0 (2025-09-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for `streamable-http` transport type
|
||||
|
||||
## v0.11.1 (2025-09-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.13,<1.14
|
||||
|
||||
## v0.11.0 (2025-09-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **deck**: Add support for stack, cards, labels
|
||||
- **deck**: Initialize Deck app client/server
|
||||
|
||||
## 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
|
||||
|
||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||
|
||||
## v0.7.0 (2025-08-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- **contacts**: Initialize Contacts App
|
||||
|
||||
## v0.6.1 (2025-08-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- **calendar**: Fix iCalendar date vs datetime format
|
||||
- **calendar**: Remove try/except in calendar API
|
||||
|
||||
## v0.6.0 (2025-07-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to pass CI checks
|
||||
- **calendar**: address PR feedback from maintainer
|
||||
|
||||
### Refactor
|
||||
|
||||
- **calendar**: optimize logging for production readiness
|
||||
|
||||
## v0.5.0 (2025-07-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- Update webdav client create_directory method to handle recursive directories
|
||||
- **webdav**: add complete file system support
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to test_webdav_operations.py
|
||||
|
||||
## v0.4.1 (2025-07-10)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||
|
||||
## v0.4.0 (2025-07-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add TablesClient and associated tools
|
||||
|
||||
### Fix
|
||||
|
||||
- update tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Modularize NC and Notes app client
|
||||
|
||||
## v0.3.0 (2025-06-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- Switch to using async client
|
||||
|
||||
## v0.2.5 (2025-05-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- Commitizen release process
|
||||
|
||||
## v0.2.4 (2025-05-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- Do not update dependencies when running in Dockerfile
|
||||
- Configure logging
|
||||
|
||||
## v0.2.3 (2025-05-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- Limit search results to notes with score > 0.5
|
||||
|
||||
## v0.2.2 (2025-05-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- Install deps before checking service
|
||||
|
||||
## v0.2.1 (2025-05-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- Install deps before checking service
|
||||
|
||||
## v0.2.1 (2025-05-24)
|
||||
|
||||
## v0.2.0 (2025-05-24)
|
||||
|
||||
### Feat
|
||||
|
||||
- **notes**: Add append to note functionality
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.9,<1.10
|
||||
|
||||
## v0.1.3 (2025-05-16)
|
||||
|
||||
## v0.1.2 (2025-05-05)
|
||||
|
||||
## v0.1.1 (2025-05-05)
|
||||
|
||||
## v0.1.0 (2025-05-05)
|
||||
@@ -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
|
||||
+3
-7
@@ -1,13 +1,9 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV FASTMCP_LOG_LEVEL=DEBUG
|
||||
|
||||
CMD ["mcp", "run", "--transport", "sse", "nextcloud_mcp_server/server.py:mcp"]
|
||||
ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -6,26 +6,55 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models (
|
||||
|
||||
## Features
|
||||
|
||||
Currently, the server primarily interacts with the Nextcloud Notes API, providing tools and resources to manage notes.
|
||||
The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources.
|
||||
|
||||
### Available Tools
|
||||
## Supported Nextcloud Apps
|
||||
|
||||
* `nc_notes_create_note`: Create a new note.
|
||||
* `nc_notes_update_note`: Update an existing note by ID.
|
||||
* `nc_notes_delete_note`: Delete a note by ID.
|
||||
| App | Support Status | Description |
|
||||
|-----|----------------|-------------|
|
||||
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
|
||||
| **Calendar** | ✅ Full Support | Complete calendar integration - create, update, delete events. Support for recurring events, reminders, attendees, and all-day events via CalDAV. |
|
||||
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
|
||||
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
|
||||
| **Contacts** | ✅ Full Support | Create, read, update, and delete contacts and address books via CardDAV. |
|
||||
| **Deck** | ✅ Full Support | Complete project management - boards, stacks, cards, labels, user assignments. Full CRUD operations and advanced features. |
|
||||
| **Tasks** | ❌ [Not Started](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) | TBD |
|
||||
|
||||
### Available Resources
|
||||
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 & 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 |
|
||||
|
||||
|
||||
### 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`
|
||||
|
||||
* `notes://{note_id}`: Access a specific note by its ID.
|
||||
* `notes://all`: Access all notes.
|
||||
* `notes://settings`: Access note settings.
|
||||
* `nc://capabilities`: Access Nextcloud server capabilities.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Python 3.8+
|
||||
* Python 3.11+
|
||||
* Access to a Nextcloud instance
|
||||
|
||||
### Local Installation
|
||||
@@ -35,9 +64,30 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin
|
||||
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
|
||||
```
|
||||
|
||||
3. Run the CLI --help command to see all available options
|
||||
```bash
|
||||
$ uv run python -m nextcloud_mcp_server.app --help
|
||||
Usage: python -m nextcloud_mcp_server.app [OPTIONS]
|
||||
|
||||
Options:
|
||||
-h, --host TEXT [default: 127.0.0.1]
|
||||
-p, --port INTEGER [default: 8000]
|
||||
-w, --workers INTEGER
|
||||
-r, --reload
|
||||
-l, --log-level [critical|error|warning|info|debug|trace]
|
||||
[default: info]
|
||||
-t, --transport [sse|streamable-http]
|
||||
[default: sse]
|
||||
-e, --enable-app [notes|tables|webdav|calendar|contacts|deck]
|
||||
Enable specific Nextcloud app APIs. Can be
|
||||
specified multiple times. If not specified,
|
||||
all apps are enabled.
|
||||
--help Show this message and exit.
|
||||
```
|
||||
|
||||
### Docker
|
||||
@@ -59,41 +109,136 @@ NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
|
||||
* `NEXTCLOUD_USERNAME`: Your Nextcloud username.
|
||||
* `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure.
|
||||
|
||||
## Transport Types
|
||||
|
||||
The server supports two transport types for MCP communication:
|
||||
|
||||
### Streamable HTTP (Recommended)
|
||||
The `streamable-http` transport is the recommended and modern transport type that provides improved streaming capabilities:
|
||||
|
||||
```bash
|
||||
# Use streamable-http transport (recommended)
|
||||
uv run python -m nextcloud_mcp_server.app --transport streamable-http
|
||||
```
|
||||
|
||||
### SSE (Server-Sent Events) - Deprecated
|
||||
> [!WARNING]
|
||||
> ⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version of the MCP spec. SSE will be supported for the foreseable future, but users are encouraged to switch to the new transport type. Please migrate to `streamable-http`.
|
||||
|
||||
```bash
|
||||
# SSE transport (deprecated - for backwards compatibility only)
|
||||
uv run python -m nextcloud_mcp_server.app --transport sse
|
||||
```
|
||||
|
||||
#### Docker Usage with Transports
|
||||
|
||||
```bash
|
||||
# Using SSE transport (default - deprecated)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# Using streamable-http transport (recommended)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--transport streamable-http
|
||||
```
|
||||
|
||||
**Note:** When using MCP clients, ensure your client supports the transport type you've configured on the server. Most modern MCP clients support streamable-http.
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Locally
|
||||
|
||||
Ensure your environment variables are loaded, then run the server 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)
|
||||
|
||||
# Run the app module directly with custom options
|
||||
uv run python -m nextcloud_mcp_server.app --host 0.0.0.0 --port 8080 --log-level info
|
||||
|
||||
# Enable only specific Nextcloud app APIs
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar
|
||||
|
||||
# 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.server: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://0.0.0.0:8000`.
|
||||
The server will start, typically listening on `http://127.0.0.1:8000`.
|
||||
|
||||
**Host binding options:**
|
||||
- Use `--host 0.0.0.0` to bind to all interfaces
|
||||
- Use `--host 127.0.0.1` to bind only to localhost (default)
|
||||
|
||||
See the full list of available `uvicorn` options and how to set them at [https://www.uvicorn.org/settings/]()
|
||||
|
||||
### Selective App Enablement
|
||||
|
||||
By default, all supported Nextcloud app APIs are enabled. You can selectively enable only specific apps using the `--enable-app` option:
|
||||
|
||||
```bash
|
||||
# Available apps: notes, tables, webdav, calendar, contacts, deck
|
||||
|
||||
# Enable all apps (default behavior)
|
||||
uv run python -m nextcloud_mcp_server.app
|
||||
|
||||
# Enable only Notes and Calendar
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar
|
||||
|
||||
# Enable only WebDAV for file operations
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app webdav
|
||||
|
||||
# Enable multiple apps by repeating the option
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app tables --enable-app contacts
|
||||
```
|
||||
|
||||
This can be useful for:
|
||||
- Reducing memory usage and startup time
|
||||
- Limiting available functionality for security or organizational reasons
|
||||
- Testing specific app integrations
|
||||
- Running lightweight instances with only needed features
|
||||
|
||||
### Using Docker
|
||||
|
||||
Mount your environment file when running the container:
|
||||
|
||||
```bash
|
||||
# Run with all apps enabled (default)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# Run with only specific apps enabled
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--enable-app notes --enable-app calendar
|
||||
|
||||
# Run with only WebDAV
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--enable-app webdav
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -105,4 +250,6 @@ Contributions are welcome! Please feel free to submit issues or pull requests on
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "Installing and configuring Calendar app..."
|
||||
|
||||
# Enable calendar app
|
||||
php /var/www/html/occ app:enable calendar
|
||||
|
||||
# Wait for calendar app to be fully initialized
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
sleep 5
|
||||
|
||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
||||
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
||||
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||
|
||||
# Ensure maintenance mode is off before calendar operations
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
|
||||
# Sync DAV system to ensure proper initialization
|
||||
echo "Syncing DAV system..."
|
||||
php /var/www/html/occ dav:sync-system-addressbook
|
||||
|
||||
# Repair calendar app to ensure proper setup
|
||||
echo "Repairing calendar app..."
|
||||
php /var/www/html/occ maintenance:repair --include-expensive
|
||||
|
||||
# Final wait to ensure CalDAV service is fully ready
|
||||
echo "Final CalDAV initialization wait..."
|
||||
sleep 5
|
||||
|
||||
echo "Calendar app installation complete!"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable contacts
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable deck
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable notes
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable tables
|
||||
+18
-4
@@ -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
|
||||
image: mariadb:lts@sha256:ec5d50f32359ff020b93cce6834f9bf89147c34aea0e90c952ccf556c94a4fb8
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,24 +17,26 @@ 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
|
||||
image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud
|
||||
image: nextcloud:31.0.8@sha256:c3329db9d0d0d79b1fe6433b54b81c28acaefecfe96a400be202b7da80f6b8ca
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
#- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done
|
||||
#user: root
|
||||
ports:
|
||||
- 8080:80
|
||||
- 127.0.0.1:8080:80
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
- NEXTCLOUD_ADMIN_PASSWORD=admin
|
||||
- MYSQL_PASSWORD=password
|
||||
@@ -42,6 +44,18 @@ services:
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http"]
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_USERNAME=admin
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
#volumes:
|
||||
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# ADR-001: Enhanced Note Search with Token-Based Relevance Ranking
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
The current search implementation in the Nextcloud MCP server performs simple substring matching without relevance ranking. The existing method:
|
||||
1. Fetches all notes
|
||||
2. Performs case-insensitive substring matching on title and content
|
||||
3. Returns matches without any ordering by relevance
|
||||
|
||||
This approach has several limitations:
|
||||
- Requires exact substring matches
|
||||
- No ranking by relevance
|
||||
- Only finds notes where the exact query string appears
|
||||
- Cannot prioritize more important matches (e.g., title vs content)
|
||||
- Inefficient for large note collections
|
||||
|
||||
We need to improve the search functionality without adding external dependencies to enhance the user experience while maintaining simplicity.
|
||||
|
||||
## Decision
|
||||
We will implement a token-based search with relevance ranking that:
|
||||
1. Splits queries and note content into individual tokens (words)
|
||||
2. Matches based on tokens rather than complete substrings
|
||||
3. Applies weighted scoring with title matches valued higher than content matches
|
||||
4. Sorts results by relevance score
|
||||
5. Maintains backward compatibility with the existing API
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Query Processing
|
||||
The search query will be tokenized (split into individual words), normalized (converted to lowercase), and filtered for stop words if necessary:
|
||||
|
||||
```python
|
||||
def process_query(query: str) -> list[str]:
|
||||
# Convert to lowercase and split into tokens
|
||||
tokens = query.lower().split()
|
||||
# Filter out very short tokens (optional)
|
||||
tokens = [token for token in tokens if len(token) > 1]
|
||||
# Could add stop word removal here
|
||||
return tokens
|
||||
```
|
||||
|
||||
### 2. Note Content Processing
|
||||
Each note's title and content will be processed in a similar way:
|
||||
|
||||
```python
|
||||
def process_note_content(note: dict) -> tuple[list[str], list[str]]:
|
||||
# Process title
|
||||
title = note.get("title", "").lower()
|
||||
title_tokens = title.split()
|
||||
|
||||
# Process content
|
||||
content = note.get("content", "").lower()
|
||||
content_tokens = content.split()
|
||||
|
||||
return title_tokens, content_tokens
|
||||
```
|
||||
|
||||
### 3. Scoring Algorithm
|
||||
We'll implement a scoring function that:
|
||||
- Assigns higher weight to title matches (e.g., 3x more important than content matches)
|
||||
- Considers the percentage of query tokens that match
|
||||
- Factors in the frequency of matches
|
||||
|
||||
```python
|
||||
def calculate_score(query_tokens: list[str], title_tokens: list[str], content_tokens: list[str]) -> float:
|
||||
# Constants for weighting
|
||||
TITLE_WEIGHT = 3.0
|
||||
CONTENT_WEIGHT = 1.0
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Count matches in title
|
||||
title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
title_match_ratio = title_matches / len(query_tokens)
|
||||
score += TITLE_WEIGHT * title_match_ratio
|
||||
|
||||
# Count matches in content
|
||||
content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
content_match_ratio = content_matches / len(query_tokens)
|
||||
score += CONTENT_WEIGHT * content_match_ratio
|
||||
|
||||
# If no tokens matched at all, return zero
|
||||
if title_matches == 0 and content_matches == 0:
|
||||
return 0.0
|
||||
|
||||
return score
|
||||
```
|
||||
|
||||
### 4. Enhanced Search Implementation
|
||||
|
||||
```python
|
||||
def notes_search_notes(self, *, query: str):
|
||||
"""
|
||||
Search notes using token-based matching with relevance ranking.
|
||||
Returns notes sorted by relevance score.
|
||||
"""
|
||||
all_notes = self.notes_get_all()
|
||||
search_results = []
|
||||
|
||||
# Process the query
|
||||
query_tokens = process_query(query)
|
||||
|
||||
# If empty query after processing, return empty results
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
# Process and score each note
|
||||
for note in all_notes:
|
||||
title_tokens, content_tokens = process_note_content(note)
|
||||
score = calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
# Only include notes with a non-zero score
|
||||
if score > 0:
|
||||
search_results.append({
|
||||
"id": note.get("id"),
|
||||
"title": note.get("title"),
|
||||
"category": note.get("category"),
|
||||
"modified": note.get("modified"),
|
||||
"_score": score # Include score for sorting (optional field)
|
||||
})
|
||||
|
||||
# Sort by score in descending order
|
||||
search_results.sort(key=lambda x: x["_score"], reverse=True)
|
||||
|
||||
# Remove score field before returning (optional)
|
||||
for result in search_results:
|
||||
if "_score" in result:
|
||||
del result["_score"]
|
||||
|
||||
return search_results
|
||||
```
|
||||
|
||||
### 5. Performance Considerations
|
||||
- The enhanced search still retrieves all notes from the server, which could be inefficient for large collections
|
||||
- Future improvements could include caching or building an in-memory index
|
||||
- For very large note collections, consider adding pagination to the API
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
1. Better search results with matches on individual words instead of exact phrases
|
||||
2. Relevant results appear first due to ranking
|
||||
3. Title matches are prioritized, matching user expectations
|
||||
4. No additional dependencies required
|
||||
5. Maintains backward compatibility with existing API
|
||||
|
||||
### Limitations
|
||||
1. Slightly increased complexity in the search implementation
|
||||
2. Still requires fetching all notes for each search operation
|
||||
3. No handling of typos or similar words (would require fuzzy matching)
|
||||
4. No stemming/lemmatization to match word variations
|
||||
|
||||
### Future Potential Enhancements
|
||||
1. Add support for phrase queries (exact matches)
|
||||
2. Implement an in-memory index for faster repeated searches
|
||||
3. Add basic natural language processing features (stemming, stop words)
|
||||
4. Support for fuzzy matching to handle typos
|
||||
|
||||
## Alternatives Considered
|
||||
1. Implementing a full-text search engine (e.g., integrating with Elasticsearch)
|
||||
2. Using vector-based semantic search with embeddings
|
||||
3. Adding external NLP libraries for more sophisticated text processing
|
||||
|
||||
These alternatives were not selected for the initial implementation due to the desire to maintain simplicity and avoid adding dependencies, but could be considered for future enhancements.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Calendar App
|
||||
|
||||
### Calendar Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_calendar_list_calendars` | List all available calendars for the user |
|
||||
| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) |
|
||||
| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) |
|
||||
| `nc_calendar_get_event` | Get detailed information about a specific event |
|
||||
| `nc_calendar_update_event` | Update any aspect of an existing event |
|
||||
| `nc_calendar_delete_event` | Delete a calendar event |
|
||||
| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults |
|
||||
| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days |
|
||||
| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection |
|
||||
| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria |
|
||||
| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties |
|
||||
|
||||
### Calendar Integration
|
||||
|
||||
The server provides comprehensive calendar integration through CalDAV, enabling you to:
|
||||
|
||||
- List all available calendars
|
||||
- Create, read, update, and delete calendar events
|
||||
- Handle recurring events with RRULE support
|
||||
- Manage event reminders and notifications
|
||||
- Support all-day and timed events
|
||||
- Handle attendees and meeting invitations
|
||||
- Organize events with categories and priorities
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List available calendars
|
||||
calendars = await nc_calendar_list_calendars()
|
||||
|
||||
# Create a simple event
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="personal",
|
||||
title="Team Meeting",
|
||||
start_datetime="2025-07-28T14:00:00",
|
||||
end_datetime="2025-07-28T15:00:00",
|
||||
description="Weekly team sync",
|
||||
location="Conference Room A"
|
||||
)
|
||||
|
||||
# Create a recurring weekly meeting
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="work",
|
||||
title="Weekly Standup",
|
||||
start_datetime="2025-07-28T09:00:00",
|
||||
end_datetime="2025-07-28T09:30:00",
|
||||
recurring=True,
|
||||
recurrence_rule="FREQ=WEEKLY;BYDAY=MO"
|
||||
)
|
||||
|
||||
# Quick meeting creation
|
||||
await nc_calendar_create_meeting(
|
||||
title="Client Call",
|
||||
date="2025-07-28",
|
||||
time="15:00",
|
||||
duration_minutes=60,
|
||||
attendees="client@example.com,colleague@company.com"
|
||||
)
|
||||
|
||||
# Get upcoming events
|
||||
events = await nc_calendar_get_upcoming_events(days_ahead=7)
|
||||
|
||||
# Advanced search - find all meetings with 5+ attendees lasting 2+ hours
|
||||
long_meetings = await nc_calendar_list_events(
|
||||
calendar_name="", # Search all calendars
|
||||
search_all_calendars=True,
|
||||
start_date="2025-07-01",
|
||||
end_date="2025-07-31",
|
||||
min_attendees=5,
|
||||
min_duration_minutes=120,
|
||||
title_contains="meeting"
|
||||
)
|
||||
|
||||
# Find availability for a 1-hour meeting with specific attendees
|
||||
availability = await nc_calendar_find_availability(
|
||||
duration_minutes=60,
|
||||
attendees="sarah@company.com,mike@company.com",
|
||||
date_range_start="2025-07-28",
|
||||
date_range_end="2025-08-04",
|
||||
business_hours_only=True,
|
||||
exclude_weekends=True,
|
||||
preferred_times="09:00-12:00,14:00-17:00"
|
||||
)
|
||||
|
||||
# Bulk update all team meetings to new location
|
||||
bulk_result = await nc_calendar_bulk_operations(
|
||||
operation="update",
|
||||
title_contains="team meeting",
|
||||
start_date="2025-08-01",
|
||||
end_date="2025-08-31",
|
||||
new_location="Conference Room B",
|
||||
new_reminder_minutes=15
|
||||
)
|
||||
|
||||
# Create a new project calendar
|
||||
new_calendar = await nc_calendar_manage_calendar(
|
||||
action="create",
|
||||
calendar_name="project-alpha",
|
||||
display_name="Project Alpha Calendar",
|
||||
description="Calendar for Project Alpha team",
|
||||
color="#FF5722"
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contacts App
|
||||
|
||||
### Contacts Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_contacts_list_addressbooks` | List all available addressbooks for the user |
|
||||
| `nc_contacts_list_contacts` | List all contacts in a specific addressbook |
|
||||
| `nc_contacts_create_addressbook` | Create a new addressbook |
|
||||
| `nc_contacts_delete_addressbook` | Delete an addressbook |
|
||||
| `nc_contacts_create_contact` | Create a new contact in an addressbook |
|
||||
| `nc_contacts_delete_contact` | Delete a contact from an addressbook |
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Deck App
|
||||
|
||||
### Deck Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `deck_create_board` | Create a new Deck board with title and color |
|
||||
| `deck_create_stack` | Create a new stack in a board |
|
||||
| `deck_update_stack` | Update stack title and order |
|
||||
| `deck_delete_stack` | Delete a stack and all its cards |
|
||||
| `deck_create_card` | Create a new card in a stack with full options (title, description, due date, etc.) |
|
||||
| `deck_update_card` | Update any aspect of a card (title, description, owner, order, etc.) |
|
||||
| `deck_delete_card` | Delete a card |
|
||||
| `deck_archive_card` | Archive a card |
|
||||
| `deck_unarchive_card` | Unarchive a card |
|
||||
| `deck_reorder_card` | Move/reorder cards within or between stacks |
|
||||
| `deck_create_label` | Create a new label in a board |
|
||||
| `deck_update_label` | Update label title and color |
|
||||
| `deck_delete_label` | Delete a label |
|
||||
| `deck_assign_label_to_card` | Assign a label to a card |
|
||||
| `deck_remove_label_from_card` | Remove a label from a card |
|
||||
| `deck_assign_user_to_card` | Assign a user to a card |
|
||||
| `deck_unassign_user_from_card` | Remove a user assignment from a card |
|
||||
|
||||
### Deck Resources
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `nc://Deck/boards` | List all deck boards |
|
||||
| `nc://Deck/boards/{board_id}` | Get details of a specific board |
|
||||
| `nc://Deck/boards/{board_id}/stacks` | List all stacks in a board |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}` | Get details of a specific stack |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards` | List all cards in a stack |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` | Get details of a specific card |
|
||||
| `nc://Deck/boards/{board_id}/labels` | List all labels in a board |
|
||||
| `nc://Deck/boards/{board_id}/labels/{label_id}` | Get details of a specific label |
|
||||
|
||||
|
||||
|
||||
### Deck Project Management
|
||||
|
||||
The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows:
|
||||
|
||||
- Create and manage boards, stacks, and cards
|
||||
- Organize tasks with labels and user assignments
|
||||
- Archive/unarchive cards and reorder within or between stacks
|
||||
- Full CRUD operations on all Deck entities
|
||||
- Browse project structure through hierarchical resources
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# Create a new project board
|
||||
await deck_create_board(title="Website Redesign", color="1976D2")
|
||||
|
||||
# Create workflow stacks
|
||||
await deck_create_stack(board_id=1, title="To Do", order=1)
|
||||
await deck_create_stack(board_id=1, title="In Progress", order=2)
|
||||
await deck_create_stack(board_id=1, title="Done", order=3)
|
||||
|
||||
# Create task cards with details
|
||||
await deck_create_card(
|
||||
board_id=1,
|
||||
stack_id=1,
|
||||
title="Design new homepage",
|
||||
description="Create mockups for the new homepage layout",
|
||||
type="plain",
|
||||
order=1,
|
||||
duedate="2025-08-15T17:00:00"
|
||||
)
|
||||
|
||||
# Create and assign labels for organization
|
||||
await deck_create_label(board_id=1, title="High Priority", color="F44336")
|
||||
await deck_create_label(board_id=1, title="UI/UX", color="9C27B0")
|
||||
|
||||
# Assign labels and users to cards
|
||||
await deck_assign_label_to_card(board_id=1, stack_id=1, card_id=1, label_id=1)
|
||||
await deck_assign_user_to_card(board_id=1, stack_id=1, card_id=1, user_id="designer")
|
||||
|
||||
# Move cards through workflow
|
||||
await deck_reorder_card(
|
||||
board_id=1,
|
||||
stack_id=1, # From "To Do"
|
||||
card_id=1,
|
||||
order=1,
|
||||
target_stack_id=2 # To "In Progress"
|
||||
)
|
||||
|
||||
# Update task progress
|
||||
await deck_update_card(
|
||||
board_id=1,
|
||||
stack_id=2,
|
||||
card_id=1,
|
||||
description="Homepage mockups completed, starting development",
|
||||
order=1
|
||||
)
|
||||
|
||||
# Complete tasks
|
||||
await deck_reorder_card(
|
||||
board_id=1,
|
||||
stack_id=2, # From "In Progress"
|
||||
card_id=1,
|
||||
order=1,
|
||||
target_stack_id=3 # To "Done"
|
||||
)
|
||||
|
||||
# Archive completed cards
|
||||
await deck_archive_card(board_id=1, stack_id=3, card_id=1)
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Working with Images in Nextcloud Notes
|
||||
|
||||
This document explains how to properly work with images and attachments in Nextcloud Notes through the MCP server.
|
||||
|
||||
## Adding Image Attachments
|
||||
|
||||
Images and other files can be attached to notes using the WebDAV protocol. The Nextcloud MCP server handles this through the `add_note_attachment` method:
|
||||
|
||||
```python
|
||||
# Example: Adding an image attachment to a note
|
||||
client.add_note_attachment(
|
||||
note_id=123, # The ID of the note
|
||||
filename="image.png", # The filename for the attachment
|
||||
content=image_bytes, # The binary content of the image
|
||||
mime_type="image/png" # The MIME type
|
||||
)
|
||||
```
|
||||
|
||||
## Embedding Images in Notes
|
||||
|
||||
For images to display inline within notes, you must reference them correctly in the note content. There are two methods:
|
||||
|
||||
### 1. Markdown Syntax (Recommended)
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
For example:
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### 2. HTML Image Tags
|
||||
|
||||
```html
|
||||
<img src=".attachments.{note_id}/{filename}" alt="Image description" width="300" />
|
||||
```
|
||||
|
||||
For example:
|
||||
```html
|
||||
<img src=".attachments.123/screenshot.png" alt="My Screenshot" width="300" />
|
||||
```
|
||||
|
||||
## Storage Location
|
||||
|
||||
Image attachments are stored in a hidden directory structure:
|
||||
|
||||
```
|
||||
/Notes/.attachments.{note_id}/{filename}
|
||||
```
|
||||
|
||||
This path is accessible via WebDAV, allowing direct file operations.
|
||||
|
||||
## Orphaned Attachments Behavior
|
||||
|
||||
**Important:** When notes are deleted, their attachments remain in the system. This is the expected behavior of the official Nextcloud Notes app, not a bug in the MCP server implementation.
|
||||
|
||||
Consequences:
|
||||
- Orphaned attachments accumulate over time
|
||||
- No automatic cleanup of attachment directories
|
||||
- References to attachments in deleted notes become broken links
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Example: Creating a Note with Embedded Image
|
||||
|
||||
```python
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
import os
|
||||
|
||||
# Create client
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# 1. Create the note
|
||||
note = client.notes_create_note(
|
||||
title="Note with Embedded Image",
|
||||
content="# Image Example\n\nThis note will have an embedded image.",
|
||||
category="Documentation"
|
||||
)
|
||||
note_id = note["id"]
|
||||
note_etag = note["etag"]
|
||||
|
||||
# 2. Read image content
|
||||
with open("example.png", "rb") as f:
|
||||
image_content = f.read()
|
||||
|
||||
# 3. Upload image as attachment
|
||||
client.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename="example.png",
|
||||
content=image_content,
|
||||
mime_type="image/png"
|
||||
)
|
||||
|
||||
# 4. Update note content to include image reference
|
||||
updated_content = f"""# Image Example
|
||||
|
||||
This note has an embedded image below:
|
||||
|
||||

|
||||
"""
|
||||
|
||||
# 5. Update the note with image reference
|
||||
client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=note_etag,
|
||||
content=updated_content
|
||||
)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with attachments:
|
||||
|
||||
1. **401 Unauthorized errors**: Verify WebDAV permissions in Nextcloud
|
||||
2. **Images not displaying**: Check the exact path format (`.attachments.{note_id}/{filename}`)
|
||||
3. **Attachment access after note deletion**: This is expected - attachments persist after note deletion
|
||||
@@ -0,0 +1,19 @@
|
||||
# Notes App
|
||||
|
||||
### Notes Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_notes_create_note` | Create a new note with title, content, and category |
|
||||
| `nc_notes_update_note` | Update an existing note by ID |
|
||||
| `nc_notes_append_content` | Append content to an existing note with a clear separator |
|
||||
| `nc_notes_delete_note` | Delete a note by ID |
|
||||
| `nc_notes_search_notes` | Search notes by title or content |
|
||||
|
||||
### Note Attachments
|
||||
|
||||
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
|
||||
|
||||
* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app.
|
||||
* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time.
|
||||
* WebDAV permissions must be properly configured for attachment operations to work correctly.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tables App
|
||||
|
||||
### Tables Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_tables_list_tables` | List all tables available to the user |
|
||||
| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views |
|
||||
| `nc_tables_read_table` | Read rows from a table with optional pagination |
|
||||
| `nc_tables_insert_row` | Insert a new row into a table |
|
||||
| `nc_tables_update_row` | Update an existing row in a table |
|
||||
| `nc_tables_delete_row` | Delete a row from a table |
|
||||
@@ -0,0 +1,62 @@
|
||||
# WebDAV support
|
||||
|
||||
### WebDAV File System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_webdav_list_directory` | List files and directories in any NextCloud path |
|
||||
| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) |
|
||||
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
||||
| `nc_webdav_create_directory` | Create new directories |
|
||||
| `nc_webdav_delete_resource` | Delete files or directories |
|
||||
| `nc_webdav_move_resource` | Move or rename files and directories |
|
||||
| `nc_webdav_copy_resource` | Copy files and directories |
|
||||
|
||||
### WebDAV File System Access
|
||||
|
||||
The server provides complete file system access to your NextCloud instance, enabling you to:
|
||||
|
||||
- Browse any directory structure
|
||||
- Read and write files of any type
|
||||
- Create and delete directories
|
||||
- Manage your NextCloud files directly through LLM interactions
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List files in root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# Browse a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
|
||||
# Read a text file
|
||||
content = await nc_webdav_read_file("Documents/readme.txt")
|
||||
|
||||
# Create a new directory
|
||||
await nc_webdav_create_directory("NewProject/docs")
|
||||
|
||||
# Write content to a file
|
||||
await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...")
|
||||
|
||||
# Delete a file or directory
|
||||
await nc_webdav_delete_resource("old_file.txt")
|
||||
|
||||
# Move or rename a file
|
||||
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
||||
|
||||
# Move a file to another directory
|
||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
||||
|
||||
# Move a directory
|
||||
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
||||
|
||||
# Copy a file
|
||||
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
||||
|
||||
# Copy a file to another directory
|
||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
||||
|
||||
# Copy a directory
|
||||
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
||||
```
|
||||
@@ -0,0 +1,172 @@
|
||||
import click
|
||||
import logging
|
||||
import uvicorn
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from dataclasses import dataclass
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
client: NextcloudClient
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
logging.info("Creating Nextcloud client")
|
||||
client = NextcloudClient.from_env()
|
||||
logging.info("Client initialization wait complete.")
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
await client.close()
|
||||
|
||||
|
||||
async def heartbeat(request):
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
|
||||
setup_logging()
|
||||
|
||||
# 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())}"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp.settings.sse_path = "/"
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
else:
|
||||
mcp.settings.streamable_http_path = "/"
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/heartbeat", endpoint=heartbeat),
|
||||
Mount("/sse" if transport == "sse" else "/mcp", app=mcp_app),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--host", "-h", default="127.0.0.1", show_default=True)
|
||||
@click.option("--port", "-p", type=int, default=8000, show_default=True)
|
||||
@click.option("--workers", "-w", type=int, default=None)
|
||||
@click.option("--reload", "-r", is_flag=True)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
default="info",
|
||||
show_default=True,
|
||||
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
||||
)
|
||||
@click.option(
|
||||
"--transport",
|
||||
"-t",
|
||||
default="sse",
|
||||
show_default=True,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,160 +0,0 @@
|
||||
import os
|
||||
import time # Import time for sleep
|
||||
from httpx import (
|
||||
Client,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Headers,
|
||||
Request,
|
||||
Response,
|
||||
HTTPStatusError,
|
||||
) # Import HTTPStatusError
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
logger.info(
|
||||
"Request event hook ****: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.info("Request body: %s", request.content)
|
||||
logger.info("Headers: %s", request.headers)
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
response.read() # Explicitly read the stream before accessing .text
|
||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
|
||||
def __init__(self, base_url: str, auth: Auth | None = None):
|
||||
|
||||
self._client = Client(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
|
||||
logger.info("Creating NC Client using env vars")
|
||||
|
||||
host = os.environ["NEXTCLOUD_HOST"]
|
||||
username = os.environ["NEXTCLOUD_USERNAME"]
|
||||
password = os.environ["NEXTCLOUD_PASSWORD"]
|
||||
return cls(base_url=host, auth=BasicAuth(username, password))
|
||||
|
||||
def capabilities(self):
|
||||
|
||||
response = self._client.get(
|
||||
"/ocs/v2.php/cloud/capabilities",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
def notes_get_settings(self):
|
||||
response = self._client.get("/apps/notes/api/v1/settings")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_get_all(self):
|
||||
response = self._client.get("/apps/notes/api/v1/notes")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_get_note(self, *, note_id: int):
|
||||
response = self._client.get(f"/apps/notes/api/v1/notes/{note_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_create_note(
|
||||
self,
|
||||
*,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
body = {}
|
||||
if title:
|
||||
body.update({"title": title})
|
||||
if content:
|
||||
body.update({"content": content})
|
||||
if category:
|
||||
body.update({"category": category})
|
||||
|
||||
response = self._client.post(
|
||||
url="/apps/notes/api/v1/notes",
|
||||
json=body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_update_note(
|
||||
self,
|
||||
*,
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
category: str | None = None,
|
||||
):
|
||||
# body = {"etag": etag} # Removed redundant line
|
||||
body = {}
|
||||
if title:
|
||||
body.update({"title": title})
|
||||
if content:
|
||||
body.update({"content": content})
|
||||
if category:
|
||||
body.update({"category": category})
|
||||
|
||||
logger.info(
|
||||
"Attempting to update note %s with etag %s. Body: %s",
|
||||
note_id,
|
||||
etag, # This was current_etag in the loop
|
||||
body,
|
||||
)
|
||||
# Ensure conditional PUT using If-Match header is active
|
||||
response = self._client.put(
|
||||
url=f"/apps/notes/api/v1/notes/{note_id}",
|
||||
json=body,
|
||||
headers={"If-Match": f'"{etag}"'}, # This was current_etag in the loop
|
||||
)
|
||||
logger.info(
|
||||
"Update response for note %s: Status %s, Headers %s",
|
||||
note_id,
|
||||
response.status_code,
|
||||
response.headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def notes_search_notes(self, *, query: str):
|
||||
all_notes = self.notes_get_all()
|
||||
search_results = []
|
||||
query_lower = query.lower()
|
||||
for note in all_notes:
|
||||
title_lower = note.get("title", "").lower()
|
||||
content_lower = note.get("content", "").lower()
|
||||
if query_lower in title_lower or query_lower in content_lower:
|
||||
search_results.append(
|
||||
{
|
||||
"id": note.get("id"),
|
||||
"title": note.get("title"),
|
||||
"category": note.get("category"),
|
||||
"modified": note.get("modified"),
|
||||
}
|
||||
)
|
||||
return search_results
|
||||
|
||||
def notes_delete_note(self, *, note_id: int):
|
||||
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -0,0 +1,108 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from httpx import (
|
||||
AsyncClient,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
Response,
|
||||
AsyncBaseTransport,
|
||||
AsyncHTTPTransport,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_request(request: Request):
|
||||
logger.debug(
|
||||
"Request event hook: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.debug("Request body: %s", request.content)
|
||||
logger.debug("Headers: %s", request.headers)
|
||||
|
||||
|
||||
async def log_response(response: Response):
|
||||
await response.aread()
|
||||
logger.debug("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class AsyncDisableCookieTransport(AsyncBaseTransport):
|
||||
"""This Transport disable cookies from accumulating in the httpx AsyncClient
|
||||
|
||||
Thanks to: https://github.com/encode/httpx/issues/2992#issuecomment-2133258994
|
||||
"""
|
||||
|
||||
def __init__(self, transport: AsyncBaseTransport):
|
||||
self.transport = transport
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
response = await self.transport.handle_async_request(request)
|
||||
response.headers.pop("set-cookie", None)
|
||||
return response
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
"""Main Nextcloud client that orchestrates all app clients."""
|
||||
|
||||
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
||||
self.username = username
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
# Initialize app clients
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
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()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
logger.info("Creating NC Client using env vars")
|
||||
|
||||
host = os.environ["NEXTCLOUD_HOST"]
|
||||
username = os.environ["NEXTCLOUD_USERNAME"]
|
||||
password = os.environ["NEXTCLOUD_PASSWORD"]
|
||||
# Pass username to constructor
|
||||
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
||||
|
||||
async def capabilities(self):
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/cloud/capabilities",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
async def notes_search_notes(self, *, query: str):
|
||||
"""Search notes using token-based matching with relevance ranking."""
|
||||
all_notes = await self.notes.get_all_notes()
|
||||
return self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Base client for Nextcloud operations with shared authentication."""
|
||||
|
||||
import logging
|
||||
from abc import ABC
|
||||
|
||||
from functools import wraps
|
||||
import time
|
||||
from httpx import HTTPStatusError, codes, RequestError, AsyncClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_on_429(func):
|
||||
"""This decorator handles the 429 response from REST APIs
|
||||
|
||||
The `func` is assumed to be a method that is similar to `httpx.Client.get`,
|
||||
and returns an `httpx.Response` object. In the case of `Too Many Requests` HTTP
|
||||
response, the function will wait for a couple of seconds and retry the request.
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 5
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retries = 0
|
||||
|
||||
while retries < MAX_RETRIES:
|
||||
try:
|
||||
# Make GET API call
|
||||
retries += 1
|
||||
response = await func(*args, **kwargs)
|
||||
break
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# If we get a '429 Client Error: Too Many Requests'
|
||||
# error we wait a couple of seconds and do a retry
|
||||
if e.response.status_code == codes.TOO_MANY_REQUESTS:
|
||||
logger.warning(
|
||||
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}"
|
||||
)
|
||||
raise
|
||||
except RequestError as e:
|
||||
logger.warning(
|
||||
f"RequestError {e.request.url}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
|
||||
# If for loop ends without break statement
|
||||
else:
|
||||
logger.warning("All API call retries failed")
|
||||
raise RuntimeError(
|
||||
f"Maximum number of retries ({MAX_RETRIES}) exceeded without success"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseNextcloudClient(ABC):
|
||||
"""Base class for all Nextcloud app clients."""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, username: str):
|
||||
"""Initialize with shared HTTP client and username.
|
||||
|
||||
Args:
|
||||
http_client: Authenticated AsyncClient instance
|
||||
username: Nextcloud username for WebDAV operations
|
||||
"""
|
||||
self._client = http_client
|
||||
self.username = username
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
@retry_on_429
|
||||
async def _make_request(self, method: str, url: str, **kwargs):
|
||||
"""Common request wrapper with logging and error handling.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
url: Request URL
|
||||
**kwargs: Additional request parameters
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
logger.debug(f"Making {method} request to {url}")
|
||||
response = await self._client.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
"""CardDAV client for NextCloud contacts operations."""
|
||||
|
||||
import logging
|
||||
from .base import BaseNextcloudClient
|
||||
import xml.etree.ElementTree as ET
|
||||
from pythonvCard4.vcard import Contact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactsClient(BaseNextcloudClient):
|
||||
"""Client for NextCloud CardDAV contact operations."""
|
||||
|
||||
def _get_carddav_base_path(self) -> str:
|
||||
"""Helper to get the base CardDAV path for contacts."""
|
||||
return f"/remote.php/dav/addressbooks/users/{self.username}"
|
||||
|
||||
async def list_addressbooks(self):
|
||||
"""List all available addressbooks for the user."""
|
||||
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getctag />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {
|
||||
# "Depth": "0",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
response = await self._make_request(
|
||||
"PROPFIND", carddav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
|
||||
ns = {"d": "DAV:"}
|
||||
|
||||
# logger.info(response.content)
|
||||
root = ET.fromstring(response.content)
|
||||
addressbooks = []
|
||||
for response_elem in root.findall(".//d:response", ns):
|
||||
href = response_elem.find(".//d:href", ns)
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
if not href_text.endswith("/"):
|
||||
continue # Skip non-addressbook resources
|
||||
|
||||
# Extract addressbook name from href
|
||||
addressbook_name = href_text.rstrip("/").split("/")[-1]
|
||||
if not addressbook_name or addressbook_name == self.username:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
displayname_elem = prop.find(".//d:displayname", ns)
|
||||
displayname = (
|
||||
displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else addressbook_name
|
||||
)
|
||||
|
||||
getctag_elem = prop.find(".//d:getctag", ns)
|
||||
getctag = getctag_elem.text if getctag_elem is not None else None
|
||||
|
||||
addressbooks.append(
|
||||
{
|
||||
"name": addressbook_name,
|
||||
"display_name": displayname,
|
||||
"getctag": getctag,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(addressbooks)} addressbooks")
|
||||
return addressbooks
|
||||
|
||||
async def create_addressbook(self, *, name: str, display_name: str):
|
||||
"""Create a new addressbook."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{name}/"
|
||||
|
||||
prop_body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<c:addressbook/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>{display_name}</d:displayname>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:mkcol>"""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/xml",
|
||||
}
|
||||
|
||||
await self._make_request("MKCOL", url, content=prop_body, headers=headers)
|
||||
|
||||
async def delete_addressbook(self, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{name}/"
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
|
||||
"""Create a new contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
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 = contact.to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
"If-None-Match": "*",
|
||||
}
|
||||
|
||||
await self._make_request("PUT", url, content=vcard, headers=headers)
|
||||
|
||||
async def delete_contact(self, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
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."""
|
||||
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
|
||||
report_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
<card:address-data />
|
||||
</d:prop>
|
||||
</card:addressbook-query>"""
|
||||
|
||||
headers = {
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
response = await self._make_request(
|
||||
"REPORT",
|
||||
f"{carddav_path}/{addressbook}",
|
||||
content=report_body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
|
||||
|
||||
# logger.info(response.text)
|
||||
root = ET.fromstring(response.content)
|
||||
contacts = []
|
||||
for response_elem in root.findall(".//d:response", ns):
|
||||
href = response_elem.find(".//d:href", ns)
|
||||
if href is None:
|
||||
logger.info("Skip missing href")
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
# logger.info("Href text: %s", href_text)
|
||||
# if not href_text.endswith("/"):
|
||||
# logger.info("# Skip non-addressbook resources")
|
||||
# continue
|
||||
|
||||
# Extract vcard id from href
|
||||
vcard_id = href_text.rstrip("/").split("/")[-1]
|
||||
if not vcard_id:
|
||||
logger.info("Skip missing vcard_id")
|
||||
continue
|
||||
vcard_id = vcard_id.replace(".vcf", "")
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//d:propstat", ns)
|
||||
if propstat is None:
|
||||
logger.info("Skip missing propstat")
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//d:prop", ns)
|
||||
if prop is None:
|
||||
logger.info("Skip missing prop")
|
||||
continue
|
||||
|
||||
getetag_elem = prop.find(".//d:getetag", ns)
|
||||
getetag = getetag_elem.text if getetag_elem is not None else None
|
||||
|
||||
addressdata_elem = prop.find(".//card:address-data", ns)
|
||||
addressdata = (
|
||||
addressdata_elem.text if addressdata_elem is not None else None
|
||||
)
|
||||
if addressdata is None:
|
||||
logger.info("Skip missing addressdata")
|
||||
continue
|
||||
|
||||
contact = Contact.from_vcard(addressdata)
|
||||
|
||||
contacts.append(
|
||||
{
|
||||
"vcard_id": vcard_id,
|
||||
"getetag": getetag,
|
||||
"contact": {
|
||||
"fullname": contact.fn,
|
||||
"nickname": contact.nickname,
|
||||
"birthday": contact.bday,
|
||||
"email": contact.email,
|
||||
},
|
||||
"addressdata": addressdata,
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotesClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
async def get_settings(self) -> Dict[str, Any]:
|
||||
"""Get Notes app settings."""
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
||||
return response.json()
|
||||
|
||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all notes."""
|
||||
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."""
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/notes/api/v1/notes/{note_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_note(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new note."""
|
||||
body = {}
|
||||
if title:
|
||||
body["title"] = title
|
||||
if content:
|
||||
body["content"] = content
|
||||
if category:
|
||||
body["category"] = category
|
||||
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/notes/api/v1/notes", json=body
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing note."""
|
||||
# Get current note details to check for category change
|
||||
old_note = None
|
||||
try:
|
||||
if category is not None:
|
||||
old_note = await self.get_note(note_id)
|
||||
old_category = old_note.get("category", "")
|
||||
logger.info(f"Current category for note {note_id}: '{old_category}'")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not fetch current note {note_id} details before update: {e}"
|
||||
)
|
||||
old_note = None
|
||||
|
||||
# Prepare update body
|
||||
body = {}
|
||||
if title:
|
||||
body["title"] = title
|
||||
if content:
|
||||
body["content"] = content
|
||||
if category:
|
||||
body["category"] = category
|
||||
|
||||
logger.info(
|
||||
f"Attempting to update note {note_id} with etag {etag}. Body: {body}"
|
||||
)
|
||||
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/notes/api/v1/notes/{note_id}",
|
||||
json=body,
|
||||
headers={"If-Match": f'"{etag}"'},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Update response for note {note_id}: Status {response.status_code}"
|
||||
)
|
||||
updated_note = response.json()
|
||||
|
||||
# Check for category change and cleanup old attachment directory if needed
|
||||
if (
|
||||
old_note
|
||||
and category is not None
|
||||
and old_note.get("category", "") != category
|
||||
):
|
||||
logger.info(
|
||||
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||
)
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
await webdav_client.cleanup_old_attachment_directory(
|
||||
note_id=note_id, old_category=old_note.get("category", "")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error cleaning up old attachment directory for note {note_id}: {e}"
|
||||
)
|
||||
|
||||
return updated_note
|
||||
|
||||
async def delete_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Delete a note and its attachments."""
|
||||
# Fetch note details first to get category for cleanup
|
||||
try:
|
||||
note_details = await self.get_note(note_id)
|
||||
category = note_details.get("category", "")
|
||||
|
||||
# Determine potential categories for cleanup
|
||||
potential_categories = []
|
||||
if category:
|
||||
potential_categories.append(category)
|
||||
if category != "":
|
||||
potential_categories.append("") # Empty category
|
||||
|
||||
logger.info(
|
||||
f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not fetch note {note_id} details before deletion: {e}"
|
||||
)
|
||||
potential_categories = ["", "Unknown"] # Try common categories
|
||||
|
||||
# Delete the note via API
|
||||
logger.info(f"Deleting note {note_id} via API")
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/apps/notes/api/v1/notes/{note_id}"
|
||||
)
|
||||
logger.info(f"Note {note_id} deleted successfully via API")
|
||||
json_response = response.json()
|
||||
|
||||
# Clean up attachment directories
|
||||
try:
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
|
||||
for cat in potential_categories:
|
||||
try:
|
||||
await webdav_client.cleanup_note_attachments(note_id, cat)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup attachments for category '{cat}': {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during attachment cleanup: {e}")
|
||||
|
||||
return json_response
|
||||
|
||||
async def append_content(self, note_id: int, content: str) -> Dict[str, Any]:
|
||||
"""Append content to an existing note with a separator."""
|
||||
logger.info(f"Appending content to note {note_id}")
|
||||
|
||||
# Get current note
|
||||
current_note = await self.get_note(note_id)
|
||||
|
||||
# Use fixed separator for consistency
|
||||
separator = "\n---\n"
|
||||
|
||||
# Combine content
|
||||
existing_content = current_note.get("content", "")
|
||||
if existing_content:
|
||||
new_content = existing_content + separator + content
|
||||
else:
|
||||
new_content = content # No separator needed for empty notes
|
||||
|
||||
logger.info(
|
||||
f"Combining existing content ({len(existing_content)} chars) with new content ({len(content)} chars)"
|
||||
)
|
||||
|
||||
# Update with combined content
|
||||
return await self.update(
|
||||
note_id=note_id,
|
||||
etag=current_note["etag"],
|
||||
content=new_content,
|
||||
title=None, # Keep existing title
|
||||
category=None, # Keep existing category
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TablesClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
async def list_tables(self) -> List[Dict[str, Any]]:
|
||||
"""List all tables available to the user."""
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/ocs/v2.php/apps/tables/api/2/tables",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
result = response.json()
|
||||
return result["ocs"]["data"]
|
||||
|
||||
async def get_table_schema(self, table_id: int) -> Dict[str, Any]:
|
||||
"""Get the schema/structure of a specific table including columns and views."""
|
||||
# Using v1 API as v2 schema endpoint had issues during testing
|
||||
response = await self._make_request(
|
||||
"GET", f"/index.php/apps/tables/api/1/tables/{table_id}/scheme"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_table_rows(
|
||||
self, table_id: int, limit: Optional[int] = None, offset: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Read rows from a table with optional pagination."""
|
||||
params = {}
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
response = await self._make_request(
|
||||
"GET", f"/index.php/apps/tables/api/1/tables/{table_id}/rows", params=params
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_row(self, table_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Args:
|
||||
table_id: ID of the table to insert into
|
||||
data: Dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
# Transform data to API format: {"data": {"1": "text", "2": 42}}
|
||||
api_data = {str(k): v for k, v in data.items()}
|
||||
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/apps/tables/api/2/tables/{table_id}/rows",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
json={"data": api_data},
|
||||
)
|
||||
result = response.json()
|
||||
return result["ocs"]["data"]
|
||||
|
||||
async def update_row(self, row_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Args:
|
||||
row_id: ID of the row to update
|
||||
data: Dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
# Transform data to API format for v1 endpoint
|
||||
api_data = {str(k): v for k, v in data.items()}
|
||||
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/index.php/apps/tables/api/1/rows/{row_id}",
|
||||
json={"data": api_data},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_row(self, row_id: int) -> Dict[str, Any]:
|
||||
"""Delete a row from a table."""
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/index.php/apps/tables/api/1/rows/{row_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def transform_row_data(
|
||||
self, rows: List[Dict[str, Any]], columns: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Transform raw row data into more readable format using column names.
|
||||
|
||||
Args:
|
||||
rows: Raw row data from the API
|
||||
columns: Column definitions from table schema
|
||||
|
||||
Returns:
|
||||
List of rows with column names as keys instead of column IDs
|
||||
"""
|
||||
# Create mapping from column ID to column title
|
||||
column_map = {col["id"]: col["title"] for col in columns}
|
||||
|
||||
transformed_rows = []
|
||||
for row in rows:
|
||||
transformed_row = {
|
||||
"id": row["id"],
|
||||
"tableId": row["tableId"],
|
||||
"createdBy": row["createdBy"],
|
||||
"createdAt": row["createdAt"],
|
||||
"lastEditBy": row["lastEditBy"],
|
||||
"lastEditAt": row["lastEditAt"],
|
||||
"data": {},
|
||||
}
|
||||
|
||||
# Transform data array to column_name: value mapping
|
||||
for item in row["data"]:
|
||||
column_id = item["columnId"]
|
||||
column_name = column_map.get(column_id, f"column_{column_id}")
|
||||
transformed_row["data"][column_name] = item["value"]
|
||||
|
||||
transformed_rows.append(transformed_row)
|
||||
|
||||
return transformed_rows
|
||||
@@ -0,0 +1,572 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud WebDAV operations."""
|
||||
|
||||
async def delete_resource(self, path: str) -> Dict[str, Any]:
|
||||
"""Delete a resource (file or directory) via WebDAV DELETE."""
|
||||
# Ensure path ends with a slash if it's a directory
|
||||
if not path.endswith("/"):
|
||||
path_with_slash = f"{path}/"
|
||||
else:
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.debug(f"Deleting WebDAV resource: {webdav_path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First try a PROPFIND to verify resource exists
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await self._make_request(
|
||||
"PROPFIND", webdav_path, headers=propfind_headers
|
||||
)
|
||||
logger.debug(
|
||||
f"Resource exists check status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' doesn't exist, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = await self._make_request("DELETE", webdav_path, headers=headers)
|
||||
logger.debug(f"Successfully deleted WebDAV resource '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' not found, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
else:
|
||||
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_old_attachment_directory(
|
||||
self, note_id: int, old_category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up the attachment directory for a note in its old category location."""
|
||||
old_category_path_part = f"{old_category}/" if old_category else ""
|
||||
old_attachment_dir_path = (
|
||||
f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
)
|
||||
|
||||
logger.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.debug(f"Cleanup result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_note_attachments(
|
||||
self, note_id: int, category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up attachment directory for a specific note and category."""
|
||||
cat_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.debug(
|
||||
f"Cleaning up attachments for note {note_id} in category '{category}'"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.debug(f"Cleanup result for note {note_id}: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed cleaning up attachments for note {note_id}: {e}")
|
||||
raise e
|
||||
|
||||
async def add_note_attachment(
|
||||
self,
|
||||
note_id: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
category: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add/Update an attachment to a note via WebDAV PUT."""
|
||||
# Construct paths based on provided category
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
parent_dir_webdav_rel_path = (
|
||||
f"Notes/{category_path_part}{attachment_dir_segment}"
|
||||
)
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
|
||||
attachment_path = f"{parent_dir_path}/{filename}"
|
||||
|
||||
logger.debug(f"Uploading attachment '{filename}' for note {note_id}")
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
|
||||
try:
|
||||
# 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._make_request(
|
||||
"PROPFIND", notes_dir_path, headers=propfind_headers
|
||||
)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error("WebDAV authentication failed for Notes directory")
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
response=notes_dir_response,
|
||||
)
|
||||
elif notes_dir_response.status_code >= 400:
|
||||
logger.error(
|
||||
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
|
||||
)
|
||||
notes_dir_response.raise_for_status()
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
mkcol_response = await self._make_request(
|
||||
"MKCOL", parent_dir_path, headers=mkcol_headers
|
||||
)
|
||||
|
||||
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.error(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
|
||||
# Proceed with the PUT request
|
||||
response = await self._make_request(
|
||||
"PUT", attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id}"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def get_note_attachment(
|
||||
self, note_id: int, filename: str, category: Optional[str] = None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Fetch a specific attachment from a note via WebDAV GET."""
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.debug(f"Fetching attachment '{filename}' for note {note_id}")
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", attachment_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.debug(
|
||||
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Attachment '{filename}' not found for note {note_id}")
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
|
||||
"""List files and directories in the specified path via WebDAV PROPFIND."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Listing directory: {path}")
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"PROPFIND", webdav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
items = []
|
||||
|
||||
# Skip the first response (the directory itself)
|
||||
responses = root.findall(".//{DAV:}response")[1:]
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory name from href
|
||||
href_text = href.text or ""
|
||||
name = href_text.rstrip("/").split("/")[-1]
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Determine if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Get other properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = (
|
||||
int(size_elem.text)
|
||||
if size_elem is not None and size_elem.text
|
||||
else 0
|
||||
)
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = (
|
||||
content_type_elem.text if content_type_elem is not None else None
|
||||
)
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": f"{path.rstrip('/')}/{name}" if path else name,
|
||||
"is_directory": is_directory,
|
||||
"size": size if not is_directory else None,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(items)} items in directory: {path}")
|
||||
return items
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
|
||||
async def read_file(self, path: str) -> Tuple[bytes, str]:
|
||||
"""Read a file's content via WebDAV GET."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Reading file: {path}")
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", webdav_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
content_type = response.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)")
|
||||
return content, content_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error reading file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def write_file(
|
||||
self, path: str, content: bytes, content_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Write content to a file via WebDAV PUT."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Writing file: {path}")
|
||||
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(path)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"PUT", webdav_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully wrote file '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error writing file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error writing file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def create_directory(
|
||||
self, path: str, recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a directory via WebDAV MKCOL."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Creating directory: {path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request("MKCOL", webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully created directory '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# Method Not Allowed - directory already exists
|
||||
if e.response.status_code == 405:
|
||||
logger.debug(f"Directory '{path}' already exists")
|
||||
return {"status_code": 405, "message": "Directory already exists"}
|
||||
|
||||
# File Conflict - parent directory does not exist
|
||||
if e.response.status_code == 409 and recursive:
|
||||
# Extract parent directory path
|
||||
path_parts = path.strip("/").split("/")
|
||||
if len(path_parts) > 1:
|
||||
parent_dir = "/".join(path_parts[:-1])
|
||||
logger.debug(
|
||||
f"Parent directory '{parent_dir}' doesn't exist, creating recursively"
|
||||
)
|
||||
await self.create_directory(parent_dir, recursive)
|
||||
# Now try to create the original directory again
|
||||
return await self.create_directory(path, recursive)
|
||||
else:
|
||||
# This shouldn't happen for single-level directories under root
|
||||
logger.error(f"409 conflict for single-level directory '{path}'")
|
||||
raise e
|
||||
|
||||
logger.error(f"HTTP error creating directory '{path}': {e}")
|
||||
raise e
|
||||
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
|
||||
@@ -4,11 +4,8 @@ LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.FileHandler",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "http",
|
||||
# "stream": "ext://sys.stderr"
|
||||
"filename": "/tmp/nextcloud-mcp-server.log",
|
||||
"mode": "a",
|
||||
}
|
||||
},
|
||||
"formatters": {
|
||||
@@ -24,12 +21,12 @@ LOGGING_CONFIG = {
|
||||
},
|
||||
"httpx": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
"httpcore": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Controllers for utility operations."""
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Controller for notes search functionality."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class NotesSearchController:
|
||||
"""Handles notes search logic and scoring."""
|
||||
|
||||
def search_notes(
|
||||
self, notes: List[Dict[str, Any]], query: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search notes using token-based matching with relevance ranking.
|
||||
Returns notes sorted by relevance score.
|
||||
"""
|
||||
search_results = []
|
||||
query_tokens = self._process_query(query)
|
||||
|
||||
# If empty query after processing, return empty results
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
# Process and score each note
|
||||
for note in notes:
|
||||
title_tokens, content_tokens = self._process_note_content(note)
|
||||
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
# Only include notes with a non-zero score
|
||||
if score >= 0.5:
|
||||
search_results.append(
|
||||
{
|
||||
"id": note.get("id"),
|
||||
"title": note.get("title"),
|
||||
"category": note.get("category"),
|
||||
"modified": note.get("modified"),
|
||||
"_score": score, # Include score for sorting
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by score in descending order
|
||||
search_results.sort(key=lambda x: x["_score"], reverse=True)
|
||||
|
||||
return search_results
|
||||
|
||||
def _process_query(self, query: str) -> List[str]:
|
||||
"""
|
||||
Tokenize and normalize the search query.
|
||||
"""
|
||||
# Convert to lowercase and split into tokens
|
||||
tokens = query.lower().split()
|
||||
# Filter out very short tokens
|
||||
tokens = [token for token in tokens if len(token) > 1]
|
||||
return tokens
|
||||
|
||||
def _process_note_content(
|
||||
self, note: Dict[str, Any]
|
||||
) -> tuple[List[str], List[str]]:
|
||||
"""
|
||||
Tokenize and normalize note title and content.
|
||||
"""
|
||||
# Process title
|
||||
title = note.get("title", "").lower()
|
||||
title_tokens = title.split()
|
||||
|
||||
# Process content
|
||||
content = note.get("content", "").lower()
|
||||
content_tokens = content.split()
|
||||
|
||||
return title_tokens, content_tokens
|
||||
|
||||
def _calculate_score(
|
||||
self,
|
||||
query_tokens: List[str],
|
||||
title_tokens: List[str],
|
||||
content_tokens: List[str],
|
||||
) -> float:
|
||||
"""
|
||||
Calculate a relevance score for a note based on query tokens.
|
||||
"""
|
||||
# Constants for weighting
|
||||
TITLE_WEIGHT = 3.0
|
||||
CONTENT_WEIGHT = 1.0
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Count matches in title
|
||||
title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
title_match_ratio = title_matches / len(query_tokens)
|
||||
score += TITLE_WEIGHT * title_match_ratio
|
||||
|
||||
# Count matches in content
|
||||
content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
|
||||
if query_tokens: # Avoid division by zero
|
||||
content_match_ratio = content_matches / len(query_tokens)
|
||||
score += CONTENT_WEIGHT * content_match_ratio
|
||||
|
||||
# If no tokens matched at all, return zero
|
||||
if title_matches == 0 and content_matches == 0:
|
||||
return 0.0
|
||||
|
||||
return score
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -1,130 +0,0 @@
|
||||
# server.py
|
||||
import logging
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from mcp.server import Server
|
||||
from collections.abc import AsyncIterator
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
client: NextcloudClient
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
logger.info("Creating Nextcloud client")
|
||||
client = NextcloudClient.from_env()
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
client._client.close()
|
||||
|
||||
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
# client = NextcloudClient.from_env()
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.capabilities()
|
||||
|
||||
|
||||
@mcp.resource("notes://settings")
|
||||
def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_get_settings()
|
||||
|
||||
|
||||
@mcp.resource("notes://all")
|
||||
def nc_notes_get_all():
|
||||
"""Get all user notes"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_get_all()
|
||||
|
||||
|
||||
# Removed nc_notes_get_note resource
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_get_note(note_id: int, ctx: Context):
|
||||
"""Get user note using note id"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_get_note(note_id=note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None,
|
||||
content: str | None,
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
):
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_search_notes(query: str, ctx: Context):
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_search_notes(query=query)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def nc_notes_delete_note(note_id: int, ctx: Context):
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return client.notes_delete_note(note_id=note_id)
|
||||
|
||||
|
||||
def run():
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting now")
|
||||
mcp.run()
|
||||
@@ -0,0 +1,15 @@
|
||||
from .calendar import configure_calendar_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .tables import configure_tables_tools
|
||||
from .webdav import configure_webdav_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .deck import configure_deck_tools
|
||||
|
||||
__all__ = [
|
||||
"configure_calendar_tools",
|
||||
"configure_contacts_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_tables_tools",
|
||||
"configure_webdav_tools",
|
||||
]
|
||||
@@ -0,0 +1,801 @@
|
||||
import datetime as dt
|
||||
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.calendar import (
|
||||
Calendar,
|
||||
ListCalendarsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
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(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
start_datetime: str,
|
||||
ctx: Context,
|
||||
end_datetime: str = "",
|
||||
all_day: bool = False,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
categories: str = "",
|
||||
recurring: bool = False,
|
||||
recurrence_rule: str = "",
|
||||
recurrence_end_date: str = "",
|
||||
reminder_minutes: int = 15,
|
||||
reminder_email: bool = False,
|
||||
status: str = "CONFIRMED",
|
||||
priority: int = 5,
|
||||
privacy: str = "PUBLIC",
|
||||
attendees: str = "",
|
||||
url: str = "",
|
||||
color: str = "",
|
||||
):
|
||||
"""Create a comprehensive calendar event with full feature support
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to create the event in
|
||||
title: Event title
|
||||
start_datetime: ISO format: "2025-01-15T14:00:00" or "2025-01-15" for all-day
|
||||
ctx: MCP context
|
||||
end_datetime: ISO format end time, empty for all-day events
|
||||
all_day: Whether this is an all-day event
|
||||
description: Event description/details
|
||||
location: Event location
|
||||
categories: Comma-separated categories (e.g., "work,meeting")
|
||||
recurring: Whether this is a recurring event
|
||||
recurrence_rule: RFC5545 RRULE (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR")
|
||||
recurrence_end_date: When to stop recurring
|
||||
reminder_minutes: Minutes before event to send reminder
|
||||
reminder_email: Whether to send email notification
|
||||
status: Event status: CONFIRMED, TENTATIVE, or CANCELLED
|
||||
priority: Priority level 1-9 (1=highest, 9=lowest, 5=normal)
|
||||
privacy: Privacy level: PUBLIC, PRIVATE, or CONFIDENTIAL
|
||||
attendees: Comma-separated email addresses
|
||||
url: Related URL for the event
|
||||
color: Event color (hex or name)
|
||||
|
||||
Returns:
|
||||
Dict with event creation result
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
event_data = {
|
||||
"title": title,
|
||||
"start_datetime": start_datetime,
|
||||
"end_datetime": end_datetime,
|
||||
"all_day": all_day,
|
||||
"description": description,
|
||||
"location": location,
|
||||
"categories": categories,
|
||||
"recurring": recurring,
|
||||
"recurrence_rule": recurrence_rule,
|
||||
"recurrence_end_date": recurrence_end_date,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"reminder_email": reminder_email,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"privacy": privacy,
|
||||
"attendees": attendees,
|
||||
"url": url,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_list_events(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
limit: int = 50,
|
||||
min_attendees: Optional[int] = None,
|
||||
min_duration_minutes: Optional[int] = None,
|
||||
categories: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
title_contains: Optional[str] = None,
|
||||
location_contains: Optional[str] = None,
|
||||
search_all_calendars: bool = False,
|
||||
):
|
||||
"""List events in a calendar (or all calendars) within date range with advanced filtering.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to search. Ignored if search_all_calendars=True.
|
||||
ctx: MCP context
|
||||
start_date: Start date for search (YYYY-MM-DD format, e.g., "2025-01-01")
|
||||
end_date: End date for search (YYYY-MM-DD format, e.g., "2025-01-31")
|
||||
limit: Maximum number of events to return
|
||||
min_attendees: Filter events with at least this many attendees
|
||||
min_duration_minutes: Filter events with at least this duration
|
||||
categories: Filter events containing any of these categories (comma-separated, e.g., "work,meeting")
|
||||
status: Filter events by status (CONFIRMED, TENTATIVE, or CANCELLED)
|
||||
title_contains: Filter events where title contains this text
|
||||
location_contains: Filter events where location contains this text
|
||||
search_all_calendars: If True, search across all calendars instead of just one
|
||||
|
||||
Returns:
|
||||
List of events matching the filters
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Convert YYYY-MM-DD format dates to datetime objects
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
# If parsing fails, try to parse as ISO format
|
||||
try:
|
||||
start_datetime = dt.datetime.fromisoformat(start_date)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid start_date format: {start_date}")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
# For end date, set to end of day (23:59:59)
|
||||
end_datetime = dt.datetime.strptime(end_date, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
# If parsing fails, try to parse as ISO format
|
||||
try:
|
||||
end_datetime = dt.datetime.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid end_date format: {end_date}")
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
if min_attendees is not None:
|
||||
filters["min_attendees"] = min_attendees
|
||||
if min_duration_minutes is not None:
|
||||
filters["min_duration_minutes"] = min_duration_minutes
|
||||
if categories is not None:
|
||||
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||
if status is not None:
|
||||
filters["status"] = status
|
||||
if title_contains is not None:
|
||||
filters["title_contains"] = title_contains
|
||||
if location_contains is not None:
|
||||
filters["location_contains"] = location_contains
|
||||
|
||||
if search_all_calendars:
|
||||
# Search across all calendars with filters
|
||||
events = await client.calendar.search_events_across_calendars(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
return events[:limit]
|
||||
else:
|
||||
# Search in specific calendar
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
events = client.calendar._apply_event_filters(events, filters)
|
||||
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_get_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
ctx: Context,
|
||||
):
|
||||
"""Get detailed information about a specific event"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_update_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
ctx: Context,
|
||||
# All the same parameters as create_event but optional
|
||||
title: str | None = None,
|
||||
start_datetime: str | None = None,
|
||||
end_datetime: str | None = None,
|
||||
all_day: bool | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
categories: str | None = None,
|
||||
# Recurrence updates
|
||||
recurring: bool | None = None,
|
||||
recurrence_rule: str | None = None,
|
||||
# Notification updates
|
||||
reminder_minutes: int | None = None,
|
||||
reminder_email: bool | None = None,
|
||||
# Event property updates
|
||||
status: str | None = None,
|
||||
priority: int | None = None,
|
||||
privacy: str | None = None,
|
||||
attendees: str | None = None,
|
||||
url: str | None = None,
|
||||
color: str | None = None,
|
||||
etag: str = "",
|
||||
):
|
||||
"""Update any aspect of an existing event"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Build update data with only non-None values
|
||||
event_data = {}
|
||||
if title is not None:
|
||||
event_data["title"] = title
|
||||
if start_datetime is not None:
|
||||
event_data["start_datetime"] = start_datetime
|
||||
if end_datetime is not None:
|
||||
event_data["end_datetime"] = end_datetime
|
||||
if all_day is not None:
|
||||
event_data["all_day"] = all_day
|
||||
if description is not None:
|
||||
event_data["description"] = description
|
||||
if location is not None:
|
||||
event_data["location"] = location
|
||||
if categories is not None:
|
||||
event_data["categories"] = categories
|
||||
if recurring is not None:
|
||||
event_data["recurring"] = recurring
|
||||
if recurrence_rule is not None:
|
||||
event_data["recurrence_rule"] = recurrence_rule
|
||||
if reminder_minutes is not None:
|
||||
event_data["reminder_minutes"] = reminder_minutes
|
||||
if reminder_email is not None:
|
||||
event_data["reminder_email"] = reminder_email
|
||||
if status is not None:
|
||||
event_data["status"] = status
|
||||
if priority is not None:
|
||||
event_data["priority"] = priority
|
||||
if privacy is not None:
|
||||
event_data["privacy"] = privacy
|
||||
if attendees is not None:
|
||||
event_data["attendees"] = attendees
|
||||
if url is not None:
|
||||
event_data["url"] = url
|
||||
if color is not None:
|
||||
event_data["color"] = color
|
||||
|
||||
return await client.calendar.update_event(
|
||||
calendar_name, event_uid, event_data, etag
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_delete_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
ctx: Context,
|
||||
):
|
||||
"""Delete a calendar event"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_create_meeting(
|
||||
title: str,
|
||||
date: str,
|
||||
time: str,
|
||||
ctx: Context,
|
||||
duration_minutes: int = 60,
|
||||
calendar_name: str = "personal",
|
||||
attendees: str = "",
|
||||
location: str = "",
|
||||
description: str = "",
|
||||
reminder_minutes: int = 15,
|
||||
):
|
||||
"""Quick meeting creation with smart defaults
|
||||
|
||||
This is a convenience function for creating events with common meeting defaults.
|
||||
It automatically:
|
||||
- Calculates end time based on duration
|
||||
- Sets status to CONFIRMED
|
||||
- Adds a reminder
|
||||
- Uses simpler date/time inputs instead of full ISO format
|
||||
|
||||
For full control over all event properties, use nc_calendar_create_event instead.
|
||||
|
||||
Args:
|
||||
title: Meeting title
|
||||
date: Meeting date (YYYY-MM-DD format, e.g., "2025-01-15")
|
||||
time: Meeting start time (HH:MM format, e.g., "14:00")
|
||||
ctx: MCP context
|
||||
duration_minutes: Meeting duration in minutes (default: 60)
|
||||
calendar_name: Calendar to create the meeting in (default: "personal")
|
||||
attendees: Comma-separated email addresses of attendees
|
||||
location: Meeting location
|
||||
description: Meeting description/agenda
|
||||
reminder_minutes: Minutes before meeting to send reminder (default: 15)
|
||||
|
||||
Returns:
|
||||
Dict with meeting creation result
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Combine date and time for start_datetime
|
||||
start_datetime = f"{date}T{time}:00"
|
||||
|
||||
# Calculate end_datetime
|
||||
start_dt = dt.datetime.fromisoformat(start_datetime)
|
||||
end_dt = start_dt + dt.timedelta(minutes=duration_minutes)
|
||||
end_datetime = end_dt.isoformat()
|
||||
|
||||
event_data = {
|
||||
"title": title,
|
||||
"start_datetime": start_datetime,
|
||||
"end_datetime": end_datetime,
|
||||
"all_day": False,
|
||||
"description": description,
|
||||
"location": location,
|
||||
"attendees": attendees,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
"privacy": "PUBLIC",
|
||||
}
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
ctx: Context,
|
||||
calendar_name: str = "", # Empty = all calendars
|
||||
days_ahead: int = 7,
|
||||
limit: int = 10,
|
||||
):
|
||||
"""Get upcoming events in next N days"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
now = dt.datetime.now()
|
||||
end_datetime = now + dt.timedelta(days=days_ahead)
|
||||
|
||||
if calendar_name:
|
||||
# Get events from specific calendar
|
||||
return await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
else:
|
||||
# Get events from all calendars
|
||||
all_calendars = await client.calendar.list_calendars()
|
||||
all_events = []
|
||||
|
||||
for calendar in all_calendars:
|
||||
try:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar["name"],
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# Add calendar info to each event
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar["name"]
|
||||
event["calendar_display_name"] = calendar["display_name"]
|
||||
all_events.extend(events)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Sort by start time and limit
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_find_availability(
|
||||
duration_minutes: int,
|
||||
ctx: Context,
|
||||
attendees: str = "", # Comma-separated email list
|
||||
date_range_start: str = "", # "2025-07-28"
|
||||
date_range_end: str = "", # "2025-08-04"
|
||||
business_hours_only: bool = True,
|
||||
exclude_weekends: bool = True,
|
||||
preferred_times: str = "", # Comma-separated time ranges like "09:00-12:00,14:00-17:00"
|
||||
):
|
||||
"""Find available time slots for scheduling meetings.
|
||||
|
||||
This tool intelligently analyzes existing calendar events to find free time slots
|
||||
that work for all specified attendees within the given constraints.
|
||||
|
||||
Args:
|
||||
duration_minutes: Required duration for the meeting in minutes
|
||||
attendees: Comma-separated list of attendee email addresses to check availability for
|
||||
date_range_start: Start date for availability search (YYYY-MM-DD)
|
||||
date_range_end: End date for availability search (YYYY-MM-DD)
|
||||
business_hours_only: Only suggest slots during business hours (9 AM - 5 PM)
|
||||
exclude_weekends: Skip weekends when finding availability
|
||||
preferred_times: Preferred time ranges as "HH:MM-HH:MM" (comma-separated)
|
||||
|
||||
Returns:
|
||||
List of available time slots with start/end times and duration
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Parse attendees
|
||||
attendee_list = []
|
||||
if attendees:
|
||||
attendee_list = [
|
||||
email.strip() for email in attendees.split(",") if email.strip()
|
||||
]
|
||||
|
||||
# Parse preferred times
|
||||
preferred_time_list = []
|
||||
if preferred_times:
|
||||
preferred_time_list = [
|
||||
time_range.strip()
|
||||
for time_range in preferred_times.split(",")
|
||||
if time_range.strip()
|
||||
]
|
||||
|
||||
# Convert date strings to datetime objects
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
|
||||
if date_range_start:
|
||||
try:
|
||||
start_datetime = dt.datetime.strptime(date_range_start, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date_range_start format: {date_range_start}")
|
||||
|
||||
if date_range_end:
|
||||
try:
|
||||
end_datetime = dt.datetime.strptime(date_range_end, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date_range_end format: {date_range_end}")
|
||||
|
||||
# Build constraints
|
||||
constraints = {
|
||||
"business_hours_only": business_hours_only,
|
||||
"exclude_weekends": exclude_weekends,
|
||||
"preferred_times": preferred_time_list,
|
||||
}
|
||||
|
||||
return await client.calendar.find_availability(
|
||||
duration_minutes=duration_minutes,
|
||||
attendees=attendee_list,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
constraints=constraints,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_bulk_operations(
|
||||
operation: str, # "update", "delete", "move"
|
||||
ctx: Context,
|
||||
title_contains: Optional[str] = None,
|
||||
categories: Optional[str] = None, # Comma-separated
|
||||
calendar_name: Optional[str] = None,
|
||||
start_date: str = "", # "2025-07-01"
|
||||
end_date: str = "", # "2025-07-31"
|
||||
status: Optional[str] = None,
|
||||
location_contains: Optional[str] = None,
|
||||
# Update operation parameters
|
||||
new_title: Optional[str] = None,
|
||||
new_description: Optional[str] = None,
|
||||
new_location: Optional[str] = None,
|
||||
new_categories: Optional[str] = None,
|
||||
new_priority: Optional[int] = None,
|
||||
new_reminder_minutes: Optional[int] = None,
|
||||
# Move operation parameters
|
||||
target_calendar: Optional[str] = None,
|
||||
):
|
||||
"""Perform bulk operations (update/delete) on events matching filter criteria.
|
||||
|
||||
This tool allows you to efficiently modify or delete multiple events at once
|
||||
by applying filters to find matching events and then performing the specified operation.
|
||||
|
||||
Args:
|
||||
operation: Type of operation - "update" or "delete"
|
||||
title_contains: Filter events where title contains this text
|
||||
categories: Filter events containing any of these categories (comma-separated)
|
||||
calendar_name: Filter events from this specific calendar
|
||||
start_date: Filter events starting from this date (YYYY-MM-DD)
|
||||
end_date: Filter events ending before this date (YYYY-MM-DD)
|
||||
status: Filter events by status (CONFIRMED, TENTATIVE, CANCELLED)
|
||||
location_contains: Filter events where location contains this text
|
||||
|
||||
# For update operations:
|
||||
new_title: New title for matching events
|
||||
new_description: New description for matching events
|
||||
new_location: New location for matching events
|
||||
new_categories: New categories for matching events (comma-separated)
|
||||
new_priority: New priority for matching events (1-9, 5=normal)
|
||||
new_reminder_minutes: New reminder time in minutes before event
|
||||
|
||||
# For move operations:
|
||||
target_calendar: Calendar to move events to (requires operation="move")
|
||||
|
||||
Returns:
|
||||
Summary of operation results including counts and details
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
if operation not in ["update", "delete", "move"]:
|
||||
raise ValueError("Operation must be 'update', 'delete', or 'move'")
|
||||
|
||||
# Convert date strings to datetime objects
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid start_date format: {start_date}")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_datetime = dt.datetime.strptime(end_date, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid end_date format: {end_date}")
|
||||
|
||||
# Build filter criteria
|
||||
filter_criteria = {}
|
||||
if title_contains is not None:
|
||||
filter_criteria["title_contains"] = title_contains
|
||||
if categories is not None:
|
||||
filter_criteria["categories"] = [
|
||||
cat.strip() for cat in categories.split(",")
|
||||
]
|
||||
if status is not None:
|
||||
filter_criteria["status"] = status
|
||||
if location_contains is not None:
|
||||
filter_criteria["location_contains"] = location_contains
|
||||
# Add datetime strings for client compatibility
|
||||
if start_date:
|
||||
filter_criteria["start_date"] = start_date
|
||||
if end_date:
|
||||
filter_criteria["end_date"] = end_date
|
||||
|
||||
if operation == "delete":
|
||||
# Find matching events and delete them
|
||||
if calendar_name:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
)
|
||||
if filter_criteria:
|
||||
events = client.calendar._apply_event_filters(
|
||||
events, filter_criteria
|
||||
)
|
||||
else:
|
||||
events = await client.calendar.search_events_across_calendars(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
filters=filter_criteria,
|
||||
)
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
results = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
await client.calendar.delete_event(
|
||||
event.get("calendar_name", calendar_name), event["uid"]
|
||||
)
|
||||
deleted_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "deleted",
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"operation": "delete",
|
||||
"total_found": len(events),
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": failed_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
elif operation == "update":
|
||||
# Build update data
|
||||
update_data = {}
|
||||
if new_title is not None:
|
||||
update_data["title"] = new_title
|
||||
if new_description is not None:
|
||||
update_data["description"] = new_description
|
||||
if new_location is not None:
|
||||
update_data["location"] = new_location
|
||||
if new_categories is not None:
|
||||
update_data["categories"] = new_categories
|
||||
if new_priority is not None:
|
||||
update_data["priority"] = new_priority
|
||||
if new_reminder_minutes is not None:
|
||||
update_data["reminder_minutes"] = new_reminder_minutes
|
||||
|
||||
if not update_data:
|
||||
raise ValueError("No update data provided for update operation")
|
||||
|
||||
return await client.calendar.bulk_update_events(
|
||||
filter_criteria, update_data
|
||||
)
|
||||
|
||||
elif operation == "move":
|
||||
if not target_calendar:
|
||||
raise ValueError("target_calendar is required for move operation")
|
||||
|
||||
# Find matching events
|
||||
if calendar_name:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
)
|
||||
if filter_criteria:
|
||||
events = client.calendar._apply_event_filters(
|
||||
events, filter_criteria
|
||||
)
|
||||
else:
|
||||
events = await client.calendar.search_events_across_calendars(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
filters=filter_criteria,
|
||||
)
|
||||
|
||||
moved_count = 0
|
||||
failed_count = 0
|
||||
results = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
# Create event in target calendar
|
||||
event_data = {
|
||||
k: v
|
||||
for k, v in event.items()
|
||||
if k
|
||||
not in [
|
||||
"uid",
|
||||
"href",
|
||||
"etag",
|
||||
"calendar_name",
|
||||
"calendar_display_name",
|
||||
]
|
||||
}
|
||||
|
||||
await client.calendar.create_event(target_calendar, event_data)
|
||||
|
||||
# Delete from source calendar
|
||||
await client.calendar.delete_event(
|
||||
event.get("calendar_name", calendar_name), event["uid"]
|
||||
)
|
||||
|
||||
moved_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "moved",
|
||||
"title": event.get("title", ""),
|
||||
"from_calendar": event.get("calendar_name", calendar_name),
|
||||
"to_calendar": target_calendar,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"operation": "move",
|
||||
"total_found": len(events),
|
||||
"moved_count": moved_count,
|
||||
"failed_count": failed_count,
|
||||
"target_calendar": target_calendar,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_manage_calendar(
|
||||
action: str, # "create", "delete", "update", "list"
|
||||
ctx: Context,
|
||||
calendar_name: str = "",
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
color: str = "#1976D2", # Default blue color
|
||||
):
|
||||
"""Manage calendar creation, deletion, and properties.
|
||||
|
||||
This tool provides comprehensive calendar management functionality including
|
||||
creating new calendars, deleting existing ones, and updating calendar properties.
|
||||
|
||||
Args:
|
||||
action: Action to perform - "create", "delete", "update", or "list"
|
||||
calendar_name: Internal name for the calendar (required for create/delete/update)
|
||||
display_name: Human-readable name for the calendar (used for create/update)
|
||||
description: Description for the calendar (used for create/update)
|
||||
color: Hex color code for the calendar (e.g., "#1976D2" for blue)
|
||||
|
||||
Returns:
|
||||
Result of the calendar management operation
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
if action == "list":
|
||||
return await client.calendar.list_calendars()
|
||||
|
||||
elif action == "create":
|
||||
if not calendar_name:
|
||||
raise ValueError("calendar_name is required for create action")
|
||||
|
||||
return await client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=display_name or calendar_name,
|
||||
description=description,
|
||||
color=color,
|
||||
)
|
||||
|
||||
elif action == "delete":
|
||||
if not calendar_name:
|
||||
raise ValueError("calendar_name is required for delete action")
|
||||
|
||||
return await client.calendar.delete_calendar(calendar_name)
|
||||
|
||||
elif action == "update":
|
||||
if not calendar_name:
|
||||
raise ValueError("calendar_name is required for update action")
|
||||
|
||||
# Note: Calendar property updates require additional CalDAV PROPPATCH implementation
|
||||
# For now, return an informative message
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Calendar property updates require PROPPATCH implementation",
|
||||
"calendar_name": calendar_name,
|
||||
"requested_changes": {
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
"color": color,
|
||||
},
|
||||
}
|
||||
|
||||
else:
|
||||
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_create_addressbook(
|
||||
ctx: Context, *, name: str, display_name: str
|
||||
):
|
||||
"""Create a new addressbook.
|
||||
|
||||
Args:
|
||||
name: The name of the addressbook.
|
||||
display_name: The display name of the addressbook.
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.create_addressbook(
|
||||
name=name, display_name=display_name
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_create_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||
):
|
||||
"""Create a new contact.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook to create the contact in.
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.create_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
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__)
|
||||
|
||||
|
||||
def configure_notes_tools(mcp: FastMCP):
|
||||
@mcp.resource("notes://settings")
|
||||
async def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
settings_data = await client.notes.get_settings()
|
||||
return NotesSettings(**settings_data)
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
# Use uppercase 'Notes' to match the decorator
|
||||
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
|
||||
"mimeType": mime_type, # Client needs to determine this
|
||||
"data": content, # Return raw bytes/data
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@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
|
||||
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
|
||||
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(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None,
|
||||
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
|
||||
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
|
||||
) -> 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
|
||||
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) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
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) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
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})",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
ctx: Context,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.delete_row(row_id)
|
||||
@@ -0,0 +1,215 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
Returns:
|
||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
||||
|
||||
Examples:
|
||||
# List root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# List a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.list_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path to the file to read
|
||||
|
||||
Returns:
|
||||
Dict with path, content, content_type, size, and encoding (if binary)
|
||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
logger.info(result['content']) # Decoded text content
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# For text files, decode content for easier viewing
|
||||
if content_type and content_type.startswith("text/"):
|
||||
try:
|
||||
decoded_content = content.decode("utf-8")
|
||||
return {
|
||||
"path": path,
|
||||
"content": decoded_content,
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
}
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
# For binary files, return metadata and base64 encoded content
|
||||
import base64
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"content": base64.b64encode(content).decode("ascii"),
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_write_file(
|
||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||
):
|
||||
"""Write content to a file in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path where to write the file
|
||||
content: File content (text or base64 for binary)
|
||||
content_type: MIME type (auto-detected if not provided, use 'type;base64' for binary)
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating success
|
||||
|
||||
Examples:
|
||||
# Write a text file
|
||||
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
|
||||
|
||||
# Write binary data (base64 encoded)
|
||||
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Handle base64 encoded content
|
||||
if content_type and "base64" in content_type.lower():
|
||||
import base64
|
||||
|
||||
content_bytes = base64.b64decode(content)
|
||||
content_type = content_type.replace(";base64", "")
|
||||
else:
|
||||
content_bytes = content.encode("utf-8")
|
||||
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path of the directory to create
|
||||
|
||||
Returns:
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
|
||||
Examples:
|
||||
# Create a single directory
|
||||
await nc_webdav_create_directory("NewProject")
|
||||
|
||||
# Create nested directories (parent must exist)
|
||||
await nc_webdav_create_directory("Projects/MyApp/docs")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path of the file or directory to delete
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
|
||||
Examples:
|
||||
# Delete a file
|
||||
await nc_webdav_delete_resource("old_document.txt")
|
||||
|
||||
# Delete a directory (will delete all contents)
|
||||
await nc_webdav_delete_resource("temp_folder")
|
||||
"""
|
||||
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
|
||||
)
|
||||
Generated
-976
@@ -1,976 +0,0 @@
|
||||
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
description = "Reusable constraint types to use with typing.Annotated"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
|
||||
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.9.0"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
|
||||
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
||||
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "asttokens"
|
||||
version = "3.0.0"
|
||||
description = "Annotate AST trees with source code positions"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"},
|
||||
{file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
astroid = ["astroid (>=2,<4)"]
|
||||
test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
description = "The uncompromising code formatter."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"},
|
||||
{file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"},
|
||||
{file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"},
|
||||
{file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"},
|
||||
{file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"},
|
||||
{file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"},
|
||||
{file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"},
|
||||
{file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"},
|
||||
{file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"},
|
||||
{file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"},
|
||||
{file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"},
|
||||
{file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"},
|
||||
{file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"},
|
||||
{file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"},
|
||||
{file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"},
|
||||
{file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"},
|
||||
{file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"},
|
||||
{file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"},
|
||||
{file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"},
|
||||
{file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"},
|
||||
{file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"},
|
||||
{file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=8.0.0"
|
||||
mypy-extensions = ">=0.4.3"
|
||||
packaging = ">=22.0"
|
||||
pathspec = ">=0.9.0"
|
||||
platformdirs = ">=2"
|
||||
|
||||
[package.extras]
|
||||
colorama = ["colorama (>=0.4.3)"]
|
||||
d = ["aiohttp (>=3.10)"]
|
||||
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
||||
uvloop = ["uvloop (>=0.15.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"},
|
||||
{file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.8"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
|
||||
{file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "decorator"
|
||||
version = "5.2.1"
|
||||
description = "Decorators for Humans"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"},
|
||||
{file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "executing"
|
||||
version = "2.2.0"
|
||||
description = "Get the currently executing AST node of a frame, and other information"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"},
|
||||
{file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
h11 = ">=0.16"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
trio = ["trio (>=0.22.0,<1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
|
||||
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = "*"
|
||||
certifi = "*"
|
||||
httpcore = "==1.*"
|
||||
idna = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
|
||||
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipython"
|
||||
version = "9.2.0"
|
||||
description = "IPython: Productive Interactive Computing"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6"},
|
||||
{file = "ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
decorator = "*"
|
||||
ipython-pygments-lexers = "*"
|
||||
jedi = ">=0.16"
|
||||
matplotlib-inline = "*"
|
||||
pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""}
|
||||
prompt_toolkit = ">=3.0.41,<3.1.0"
|
||||
pygments = ">=2.4.0"
|
||||
stack_data = "*"
|
||||
traitlets = ">=5.13.0"
|
||||
typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""}
|
||||
|
||||
[package.extras]
|
||||
all = ["ipython[doc,matplotlib,test,test-extra]"]
|
||||
black = ["black"]
|
||||
doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinx_toml (==0.0.4)", "typing_extensions"]
|
||||
matplotlib = ["matplotlib"]
|
||||
test = ["packaging", "pytest", "pytest-asyncio (<0.22)", "testpath"]
|
||||
test-extra = ["curio", "ipykernel", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbclient", "nbformat", "numpy (>=1.23)", "pandas", "trio"]
|
||||
|
||||
[[package]]
|
||||
name = "ipython-pygments-lexers"
|
||||
version = "1.1.1"
|
||||
description = "Defines a variety of Pygments lexers for highlighting IPython code."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"},
|
||||
{file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pygments = "*"
|
||||
|
||||
[[package]]
|
||||
name = "jedi"
|
||||
version = "0.19.2"
|
||||
description = "An autocompletion tool for Python that can be used for text editors."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"},
|
||||
{file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
parso = ">=0.8.4,<0.9.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"]
|
||||
qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
|
||||
testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
description = "Python port of markdown-it. Markdown parsing, done right!"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
|
||||
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
mdurl = ">=0.1,<1.0"
|
||||
|
||||
[package.extras]
|
||||
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
|
||||
code-style = ["pre-commit (>=3.0,<4.0)"]
|
||||
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
|
||||
linkify = ["linkify-it-py (>=1,<3)"]
|
||||
plugins = ["mdit-py-plugins"]
|
||||
profiling = ["gprof2dot"]
|
||||
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
|
||||
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib-inline"
|
||||
version = "0.1.7"
|
||||
description = "Inline Matplotlib backend for Jupyter"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"},
|
||||
{file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
traitlets = "*"
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.7.1"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.7.1-py3-none-any.whl", hash = "sha256:f7e6108977db6d03418495426c7ace085ba2341b75197f8727f96f9cfd30057a"},
|
||||
{file = "mcp-1.7.1.tar.gz", hash = "sha256:eb4f1f53bd717f75dda8a1416e00804b831a8f3c331e23447a03b78f04b43a6e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=4.5"
|
||||
httpx = ">=0.27"
|
||||
httpx-sse = ">=0.4"
|
||||
pydantic = ">=2.7.2,<3.0.0"
|
||||
pydantic-settings = ">=2.5.2"
|
||||
python-dotenv = {version = ">=1.0.0", optional = true, markers = "extra == \"cli\""}
|
||||
python-multipart = ">=0.0.9"
|
||||
sse-starlette = ">=1.6.1"
|
||||
starlette = ">=0.27"
|
||||
typer = {version = ">=0.12.4", optional = true, markers = "extra == \"cli\""}
|
||||
uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""}
|
||||
|
||||
[package.extras]
|
||||
cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"]
|
||||
rich = ["rich (>=13.9.4)"]
|
||||
ws = ["websockets (>=15.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
description = "Markdown URL utilities"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
||||
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
description = "Type system extensions for programs checked with the mypy type checker."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
|
||||
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parso"
|
||||
version = "0.8.4"
|
||||
description = "A Python Parser"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"},
|
||||
{file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
|
||||
testing = ["docopt", "pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
|
||||
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
description = "Pexpect allows easy control of interactive console applications."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
|
||||
files = [
|
||||
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
|
||||
{file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
ptyprocess = ">=0.5"
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.7"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"},
|
||||
{file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
|
||||
type = ["mypy (>=1.14.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.5.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.51"
|
||||
description = "Library for building powerful interactive command lines in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07"},
|
||||
{file = "prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
wcwidth = "*"
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
description = "Run a subprocess in a pseudo terminal"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
|
||||
files = [
|
||||
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
|
||||
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pure-eval"
|
||||
version = "0.2.3"
|
||||
description = "Safely evaluate AST nodes without side effects"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
|
||||
{file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
tests = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.4"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb"},
|
||||
{file = "pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.33.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
typing-inspection = ">=0.4.0"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
|
||||
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.9.1"
|
||||
description = "Settings management using Pydantic"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"},
|
||||
{file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=2.7.0"
|
||||
python-dotenv = ">=0.21.0"
|
||||
typing-inspection = ">=0.4.0"
|
||||
|
||||
[package.extras]
|
||||
aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"]
|
||||
azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
|
||||
gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
|
||||
toml = ["tomli (>=2.0.1)"]
|
||||
yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.1"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"},
|
||||
{file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.5"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"},
|
||||
{file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=1.5,<2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"},
|
||||
{file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"},
|
||||
{file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.0.0"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"},
|
||||
{file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
markdown-it-py = ">=2.2.0"
|
||||
pygments = ">=2.13.0,<3.0.0"
|
||||
|
||||
[package.extras]
|
||||
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
description = "Tool to Detect Surrounding Shell"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
|
||||
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
description = "Sniff out which async library your code is running under"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "2.3.4"
|
||||
description = "SSE plugin for Starlette"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sse_starlette-2.3.4-py3-none-any.whl", hash = "sha256:b8100694f3f892b133d0f7483acb7aacfcf6ed60f863b31947664b6dc74e529f"},
|
||||
{file = "sse_starlette-2.3.4.tar.gz", hash = "sha256:0ffd6bed217cdbb74a84816437c609278003998b4991cd2e6872d0b35130e4d5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=4.7.0"
|
||||
starlette = ">=0.41.3"
|
||||
|
||||
[package.extras]
|
||||
examples = ["fastapi"]
|
||||
uvicorn = ["uvicorn (>=0.34.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "stack-data"
|
||||
version = "0.6.3"
|
||||
description = "Extract data from python stack frames and tracebacks for informative displays"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
|
||||
{file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
asttokens = ">=2.1.0"
|
||||
executing = ">=1.2.0"
|
||||
pure-eval = "*"
|
||||
|
||||
[package.extras]
|
||||
tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
description = "The little ASGI library that shines."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
|
||||
{file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.6.2,<5"
|
||||
|
||||
[package.extras]
|
||||
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "traitlets"
|
||||
version = "5.14.3"
|
||||
description = "Traitlets Python configuration system"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"},
|
||||
{file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
|
||||
test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.15.3"
|
||||
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd"},
|
||||
{file = "typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=8.0.0"
|
||||
rich = ">=10.11.0"
|
||||
shellingham = ">=1.3.0"
|
||||
typing-extensions = ">=3.7.4.3"
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.2"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"},
|
||||
{file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
|
||||
]
|
||||
markers = {dev = "python_version < \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.0"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"},
|
||||
{file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.2"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform != \"emscripten\""
|
||||
files = [
|
||||
{file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"},
|
||||
{file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.0"
|
||||
h11 = ">=0.8"
|
||||
|
||||
[package.extras]
|
||||
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
description = "Measures the displayed width of unicode strings in a terminal"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
|
||||
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
|
||||
]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "2dbb647edeb1a7b3091237434a59f19a67a2d84168a20431191eedab6710f1e5"
|
||||
+33
-10
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.1.0"
|
||||
version = "0.12.1"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
@@ -8,24 +8,47 @@ authors = [
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.7,<1.8)",
|
||||
"httpx (>=0.28.1,<0.29.0)"
|
||||
"mcp[cli] (>=1.13,<1.14)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nc-mcp-server = "nextcloud_mcp_server.server:run"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
log_cli = 1
|
||||
log_cli_level = "INFO"
|
||||
log_level = "INFO"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
|
||||
]
|
||||
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
tag_format = "v$version"
|
||||
version_scheme = "pep440"
|
||||
version_provider = "uv"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
ipython = "^9.0.2"
|
||||
pytest = "^8.2.2"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"commitizen>=4.8.2",
|
||||
"ipython>=9.2.0",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=6.1.1",
|
||||
"ruff>=0.11.13",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:best-practices",
|
||||
"mergeConfidence:all-badges"
|
||||
],
|
||||
"dependencyDashboard": true
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
Uses environment variables for configuration.
|
||||
"""
|
||||
|
||||
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
|
||||
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
|
||||
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
|
||||
logger.info("Creating session-scoped NextcloudClient from environment variables.")
|
||||
client = NextcloudClient.from_env()
|
||||
# Optional: Perform a quick check like getting capabilities to ensure connection works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"NextcloudClient session fixture initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
"""
|
||||
logger.info("Creating Streamable HTTP client")
|
||||
streamable_context = streamablehttp_client("http://127.0.0.1:8000/mcp")
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("MCP client session initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Fixture to create a temporary note for a test and ensure its deletion afterward.
|
||||
Yields the created note dictionary.
|
||||
"""
|
||||
|
||||
note_id = None
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Temporary Test Note {unique_suffix}"
|
||||
note_content = f"Content for temporary note {unique_suffix}"
|
||||
note_category = "TemporaryTesting"
|
||||
created_note_data = None
|
||||
|
||||
logger.info(f"Creating temporary note: {note_title}")
|
||||
try:
|
||||
created_note_data = await nc_client.notes.create_note(
|
||||
title=note_title, content=note_content, category=note_category
|
||||
)
|
||||
note_id = created_note_data.get("id")
|
||||
if not note_id:
|
||||
pytest.fail("Failed to get ID from created temporary note.")
|
||||
|
||||
logger.info(f"Temporary note created with ID: {note_id}")
|
||||
yield created_note_data # Provide the created note data to the test
|
||||
|
||||
finally:
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up temporary note ID: {note_id}")
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Successfully deleted temporary note ID: {note_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if note was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary note {note_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary note {note_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_note_with_attachment(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Fixture that creates a temporary note, adds an attachment, and cleans up both.
|
||||
Yields a tuple: (note_data, attachment_filename, attachment_content).
|
||||
Depends on the temporary_note fixture.
|
||||
"""
|
||||
|
||||
note_data = temporary_note
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data.get("category") # Get category from the note data
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = f"temp_attach_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
attachment_mime = "text/plain"
|
||||
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to temporary note ID: {note_id} (category: '{note_category or ''}')"
|
||||
)
|
||||
try:
|
||||
# Pass the category to add_note_attachment
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=note_category, # Pass the fetched category
|
||||
mime_type=attachment_mime,
|
||||
)
|
||||
assert upload_response.get("status_code") in [
|
||||
201,
|
||||
204,
|
||||
], f"Failed to upload attachment: {upload_response}"
|
||||
logger.info(f"Attachment '{attachment_filename}' added successfully.")
|
||||
|
||||
yield note_data, attachment_filename, attachment_content
|
||||
|
||||
# Cleanup for the attachment is handled by the notes_delete_note call
|
||||
# in the temporary_note fixture's finally block (which deletes the .attachments dir)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add attachment in fixture: {e}")
|
||||
pytest.fail(f"Fixture setup failed during attachment upload: {e}")
|
||||
|
||||
# Note: The temporary_note fixture's finally block will handle note deletion,
|
||||
# which should also trigger the WebDAV directory deletion attempt.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def temporary_addressbook(nc_client: NextcloudClient):
|
||||
"""
|
||||
Fixture to create a temporary addressbook for a test and ensure its deletion afterward.
|
||||
Yields the created addressbook dictionary.
|
||||
"""
|
||||
addressbook_name = f"test-addressbook-{uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Creating temporary addressbook: {addressbook_name}")
|
||||
try:
|
||||
await nc_client.contacts.create_addressbook(
|
||||
name=addressbook_name, display_name=f"Test Addressbook {addressbook_name}"
|
||||
)
|
||||
logger.info(f"Temporary addressbook created: {addressbook_name}")
|
||||
yield addressbook_name
|
||||
finally:
|
||||
logger.info(f"Cleaning up temporary addressbook: {addressbook_name}")
|
||||
try:
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
logger.info(
|
||||
f"Successfully deleted temporary addressbook: {addressbook_name}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(
|
||||
f"HTTP error deleting temporary addressbook {addressbook_name}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary addressbook {addressbook_name} already deleted (404)."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary addressbook {addressbook_name}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: str):
|
||||
"""
|
||||
Fixture to create a temporary contact in a temporary addressbook and ensure its deletion.
|
||||
Yields the created contact's UID.
|
||||
"""
|
||||
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
|
||||
addressbook_name = temporary_addressbook
|
||||
contact_data = {
|
||||
"fn": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"tel": "1234567890",
|
||||
}
|
||||
logger.info(f"Creating temporary contact in addressbook: {addressbook_name}")
|
||||
try:
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=contact_data,
|
||||
)
|
||||
logger.info(f"Temporary contact created with UID: {contact_uid}")
|
||||
yield contact_uid
|
||||
finally:
|
||||
logger.info(f"Cleaning up temporary contact: {contact_uid}")
|
||||
try:
|
||||
await nc_client.contacts.delete_contact(
|
||||
addressbook=addressbook_name, uid=contact_uid
|
||||
)
|
||||
logger.info(f"Successfully deleted temporary contact: {contact_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(
|
||||
f"HTTP error deleting temporary contact {contact_uid}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary contact {contact_uid} already deleted (404)."
|
||||
)
|
||||
except Exception as e:
|
||||
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}")
|
||||
@@ -0,0 +1,404 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is session-scoped in conftest.py
|
||||
# Note: temporary_note and temporary_note_with_attachment fixtures are function-scoped in conftest.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_attachments_add_and_get(
|
||||
nc_client: NextcloudClient, temporary_note_with_attachment: tuple
|
||||
):
|
||||
"""
|
||||
Tests adding an attachment (via fixture) and retrieving it.
|
||||
"""
|
||||
note_data, attachment_filename, attachment_content = temporary_note_with_attachment
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
|
||||
logger.info(
|
||||
f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}"
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=note_category
|
||||
)
|
||||
logger.info(
|
||||
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
||||
)
|
||||
|
||||
assert retrieved_content == attachment_content
|
||||
assert "text/plain" in retrieved_mime # Fixture uses text/plain
|
||||
logger.info("Retrieved attachment content and mime type verified successfully.")
|
||||
|
||||
|
||||
async def test_attachments_add_to_note_with_category(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests adding and retrieving an attachment specifically for a note that has a category.
|
||||
Uses temporary_note fixture and adds attachment manually within the test.
|
||||
"""
|
||||
note_data = (
|
||||
temporary_note # Note created by fixture (has category 'TemporaryTesting')
|
||||
)
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data["category"]
|
||||
logger.info(
|
||||
f"Using note ID: {note_id} with category '{note_category}' for attachment test."
|
||||
)
|
||||
|
||||
# Add attachment within the test
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = f"category_attach_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
attachment_mime = "text/plain"
|
||||
|
||||
logger.info(
|
||||
f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}"
|
||||
)
|
||||
# Pass category to add_note_attachment
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=note_category, # Pass the note's category
|
||||
mime_type=attachment_mime,
|
||||
)
|
||||
assert upload_response and "status_code" in upload_response
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']})."
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Get and Verify Attachment
|
||||
logger.info(
|
||||
f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}"
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=note_category, # Pass the note's category
|
||||
)
|
||||
logger.info(
|
||||
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
||||
)
|
||||
|
||||
assert retrieved_content == attachment_content
|
||||
assert attachment_mime in retrieved_mime
|
||||
logger.info(
|
||||
"Retrieved attachment content and mime type verified successfully for note with category."
|
||||
)
|
||||
# Cleanup is handled by the temporary_note fixture
|
||||
|
||||
|
||||
async def test_attachments_cleanup_on_note_delete(
|
||||
nc_client: NextcloudClient, temporary_note_with_attachment: tuple
|
||||
):
|
||||
"""
|
||||
Tests that the attachment (and its directory) are deleted when the parent note is deleted.
|
||||
Relies on the cleanup mechanism within notes_delete_note and the temporary_note fixture.
|
||||
"""
|
||||
note_data, attachment_filename, _ = temporary_note_with_attachment
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
|
||||
# Fixture setup already added the attachment.
|
||||
# Fixture teardown (from temporary_note) will delete the note.
|
||||
# We just need to verify the attachment is gone *after* the test finishes
|
||||
# and the fixture cleanup runs. However, pytest fixtures don't easily allow
|
||||
# checking state *after* cleanup.
|
||||
# Instead, we will manually delete the note here and verify the attachment is gone.
|
||||
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture)."
|
||||
)
|
||||
|
||||
# Manually delete the note
|
||||
logger.info(f"Manually deleting note ID: {note_id} within the test.")
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# Verify Note Is Deleted
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note:
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note.value.response.status_code == 404
|
||||
logger.info(f"Verified note {note_id} deletion (404 received).")
|
||||
|
||||
# Verify Attachment Is Deleted (via 404 on GET)
|
||||
logger.info(
|
||||
f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}"
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo_attach:
|
||||
# Pass category to get_note_attachment - although it should fail anyway
|
||||
# because the note (and thus details) are gone.
|
||||
# The client method will raise 404 from the initial notes_get_note call.
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=note_category, # Pass category, though note fetch should fail first
|
||||
)
|
||||
# Expect 404 because the note itself is gone
|
||||
assert excinfo_attach.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' correctly not found (404) after note deletion."
|
||||
)
|
||||
|
||||
# Directly verify attachment directory doesn't exist using WebDAV PROPFIND
|
||||
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
category_path_part = f"{note_category}/" if note_category else ""
|
||||
attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
# Note: The temporary_note fixture will still run its cleanup,
|
||||
# but it will find the note already deleted (404) and handle it gracefully.
|
||||
|
||||
|
||||
async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests attachment handling when a note's category is changed.
|
||||
Verifies attachment retrieval works before and after category change,
|
||||
and that cleanup targets the correct final location.
|
||||
"""
|
||||
note_id = None
|
||||
initial_category = "CategoryA"
|
||||
new_category = "CategoryB"
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Category Change Test {unique_suffix}"
|
||||
attachment_filename = f"cat_change_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
|
||||
try:
|
||||
# 1. Create note with initial category
|
||||
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content="Initial content", category=initial_category
|
||||
)
|
||||
note_id = created_note["id"]
|
||||
etag1 = created_note["etag"]
|
||||
logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Add attachment (passing initial category)
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=initial_category,
|
||||
mime_type="text/plain",
|
||||
)
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info("Attachment added successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify attachment retrieval from initial category (passing initial category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
||||
)
|
||||
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
assert retrieved_content1 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from initial category.")
|
||||
|
||||
# 4. Update note category
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
)
|
||||
# Need to fetch the latest etag after attachment add (WebDAV ops don't update note etag)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=current_etag,
|
||||
category=new_category,
|
||||
title=note_title,
|
||||
content="Updated content", # Pass required fields
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
logger.info(f"Note category updated successfully. New Etag: {etag3}")
|
||||
time.sleep(1)
|
||||
|
||||
# 5. Verify attachment retrieval from *new* category (passing new category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from new category '{new_category}'"
|
||||
)
|
||||
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=new_category
|
||||
)
|
||||
assert retrieved_content2 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from new category.")
|
||||
|
||||
# 5.1 Verify old category attachment directory is gone via WebDAV PROPFIND
|
||||
logger.info("Directly checking if old attachment directory exists in WebDAV")
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
# 5.2 Verify new category attachment directory exists via WebDAV PROPFIND
|
||||
logger.info("Directly checking if new attachment directory exists in WebDAV")
|
||||
new_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
)
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
assert status in [
|
||||
207,
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified new attachment directory exists via PROPFIND ({status} received)"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"New attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
|
||||
finally:
|
||||
# 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
|
||||
if note_id:
|
||||
logger.info(
|
||||
f"Cleaning up note ID: {note_id} (last known category: '{new_category}')"
|
||||
)
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
time.sleep(1)
|
||||
# Verify note deletion
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note_del:
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note_del.value.response.status_code == 404
|
||||
logger.info("Verified note deleted (404).")
|
||||
# Verify attachment deletion (should fail with 404 on the initial note fetch)
|
||||
with pytest.raises(HTTPStatusError) as excinfo_attach_del:
|
||||
# Pass the *last known* category, although the note fetch should fail first
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=new_category,
|
||||
)
|
||||
assert excinfo_attach_del.value.response.status_code == 404
|
||||
logger.info(
|
||||
"Verified attachment cannot be retrieved after note deletion (404)."
|
||||
)
|
||||
|
||||
# 6.1 Verify both old and new attachment directories are gone via WebDAV PROPFIND
|
||||
logger.info(
|
||||
"Directly verifying attachment directories don't exist via PROPFIND"
|
||||
)
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
|
||||
# Check new category attachment directory
|
||||
new_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
if resp.status_code in [
|
||||
200,
|
||||
207,
|
||||
]: # Successful PROPFIND means directory exists
|
||||
assert False, "New category attachment directory still exists!"
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified new category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
# Check old category attachment directory
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
try:
|
||||
resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
if resp.status_code in [
|
||||
200,
|
||||
207,
|
||||
]: # Successful PROPFIND means directory exists
|
||||
assert False, "Old category attachment directory still exists!"
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Verified all attachment directories are properly cleaned up."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup for note {note_id}: {e}")
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Integration tests for Calendar CalDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_calendar_name():
|
||||
"""Unique calendar name for testing."""
|
||||
return f"test_calendar_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_calendar(nc_client: NextcloudClient, test_calendar_name: str):
|
||||
"""Create a temporary calendar for testing and clean up afterward."""
|
||||
calendar_name = test_calendar_name
|
||||
|
||||
try:
|
||||
# Create a test calendar
|
||||
logger.info(f"Creating temporary calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=f"Test Calendar {calendar_name}",
|
||||
description="Temporary calendar for integration testing",
|
||||
color="#FF5722",
|
||||
)
|
||||
|
||||
if result["status_code"] not in [200, 201]:
|
||||
pytest.skip(f"Failed to create temporary calendar: {result}")
|
||||
|
||||
logger.info(f"Created temporary calendar: {calendar_name}")
|
||||
yield calendar_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up temporary calendar: {e}")
|
||||
pytest.skip(f"Calendar setup failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete the temporary calendar
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary calendar: {calendar_name}")
|
||||
await nc_client.calendar.delete_calendar(calendar_name)
|
||||
logger.info(f"Successfully deleted temporary calendar: {calendar_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting temporary calendar {calendar_name}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Create a temporary event for testing and clean up afterward."""
|
||||
event_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create a test event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": f"Test Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test event created by integration tests",
|
||||
"location": "Test Location",
|
||||
"categories": "testing",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary event in calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result.get("uid")
|
||||
|
||||
if not event_uid:
|
||||
pytest.fail("Failed to create temporary event")
|
||||
|
||||
logger.info(f"Created temporary event with UID: {event_uid}")
|
||||
yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if event_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary event: {event_uid}")
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
logger.info(f"Successfully deleted temporary event: {event_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary event {event_uid}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary event {event_uid}: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_calendars(nc_client: NextcloudClient):
|
||||
"""Test listing available calendars."""
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
|
||||
assert isinstance(calendars, list)
|
||||
|
||||
if not calendars:
|
||||
pytest.skip("No calendars available - Calendar app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(calendars)} calendars")
|
||||
|
||||
# Check structure of calendars
|
||||
for calendar in calendars:
|
||||
assert "name" in calendar
|
||||
assert "display_name" in calendar
|
||||
assert "href" in calendar
|
||||
# Optional fields
|
||||
assert "description" in calendar
|
||||
assert "color" in calendar
|
||||
|
||||
logger.info(f"Calendar: {calendar['name']} - {calendar['display_name']}")
|
||||
|
||||
|
||||
async def test_create_and_delete_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and deleting a basic event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Integration Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Test event for integration testing",
|
||||
"location": "Test Room",
|
||||
"categories": "testing,integration",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 3,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
assert "uid" in result
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with UID: {event_uid}")
|
||||
|
||||
# Verify event was created by retrieving it
|
||||
retrieved_event, etag = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["uid"] == event_uid
|
||||
assert retrieved_event["title"] == "Integration Test Event"
|
||||
assert retrieved_event["location"] == "Test Room"
|
||||
|
||||
# Delete event
|
||||
delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_all_day_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an all-day event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "All Day Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%d"),
|
||||
"all_day": True,
|
||||
"description": "Test all-day event",
|
||||
"categories": "testing",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created all-day event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "All Day Test Event"
|
||||
assert retrieved_event.get("all_day") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"All-day event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_recurring_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating a recurring event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Weekly Recurring Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test recurring event",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
||||
"reminder_minutes": 30,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created recurring event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Weekly Recurring Test"
|
||||
assert retrieved_event.get("recurring") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recurring event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test listing events within a date range."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
|
||||
# Get events for the next week
|
||||
start_datetime = datetime.now()
|
||||
end_datetime = datetime.now() + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
assert isinstance(events, list)
|
||||
logger.info(f"Found {len(events)} events in date range")
|
||||
|
||||
# Our temporary event should be in the list
|
||||
event_uids = [event.get("uid") for event in events]
|
||||
assert temporary_event["uid"] in event_uids
|
||||
|
||||
# Check event structure
|
||||
for event in events:
|
||||
assert "uid" in event
|
||||
assert "title" in event
|
||||
assert "start_datetime" in event
|
||||
|
||||
|
||||
async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test updating an existing event."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
event_uid = temporary_event["uid"]
|
||||
|
||||
# Update event data
|
||||
updated_data = {
|
||||
"title": "Updated Test Event Title",
|
||||
"description": "Updated description for test event",
|
||||
"location": "Updated Location",
|
||||
"priority": 1, # High priority
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.update_event(
|
||||
calendar_name, event_uid, updated_data
|
||||
)
|
||||
assert result["uid"] == event_uid
|
||||
|
||||
# Verify updates
|
||||
updated_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert updated_event["title"] == "Updated Test Event Title"
|
||||
assert updated_event["description"] == "Updated description for test event"
|
||||
assert updated_event["location"] == "Updated Location"
|
||||
assert updated_event["priority"] == 1
|
||||
|
||||
logger.info(f"Successfully updated event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event update test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with attendees."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Meeting with Attendees",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT16:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT17:00:00"),
|
||||
"description": "Test meeting with multiple attendees",
|
||||
"location": "Conference Room A",
|
||||
"attendees": "test1@example.com,test2@example.com",
|
||||
"reminder_minutes": 15,
|
||||
"status": "TENTATIVE",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with attendees, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Meeting with Attendees"
|
||||
assert "test1@example.com" in retrieved_event.get("attendees", "")
|
||||
assert retrieved_event["status"] == "TENTATIVE"
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with attendees test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_get_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test retrieving a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.calendar.get_event(calendar_name, fake_uid)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test deleting a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
result = await nc_client.calendar.delete_event(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_event_with_url_and_categories(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with URL and multiple categories."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Event with URL and Categories",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT10:30:00"),
|
||||
"description": "Test event with additional metadata",
|
||||
"categories": "work,meeting,important,quarterly",
|
||||
"url": "https://zoom.us/j/123456789",
|
||||
"privacy": "PRIVATE",
|
||||
"priority": 2,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with metadata, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Event with URL and Categories"
|
||||
assert "work" in retrieved_event.get("categories", "")
|
||||
assert "important" in retrieved_event.get("categories", "")
|
||||
assert retrieved_event.get("url") == "https://zoom.us/j/123456789"
|
||||
assert retrieved_event.get("privacy") == "PRIVATE"
|
||||
assert retrieved_event.get("priority") == 2
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with metadata test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""Test error handling for calendar operations."""
|
||||
|
||||
# Test with non-existent calendar
|
||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Integration tests for Contacts MCP tools."""
|
||||
|
||||
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_mcp_contacts_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Contacts workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
addressbook_name = f"mcp-test-addressbook-{uuid.uuid4().hex[:8]}"
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
contact_uid = f"mcp-contact-{unique_suffix}"
|
||||
contact_data = {
|
||||
"fn": f"MCP Contact {unique_suffix}",
|
||||
"email": f"mcp.contact.{unique_suffix}@example.com",
|
||||
"tel": "1234567890",
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. Create address book via MCP
|
||||
logger.info(f"Creating address book via MCP: {addressbook_name}")
|
||||
create_ab_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_create_addressbook",
|
||||
{"name": addressbook_name, "display_name": f"MCP Test {addressbook_name}"},
|
||||
)
|
||||
assert create_ab_result.isError is False
|
||||
|
||||
# 2. Verify address book creation
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
assert any(ab["name"] == addressbook_name for ab in addressbooks)
|
||||
|
||||
# 3. Create contact via MCP
|
||||
logger.info(f"Creating contact in {addressbook_name} via MCP")
|
||||
create_c_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_create_contact",
|
||||
{
|
||||
"addressbook": addressbook_name,
|
||||
"uid": contact_uid,
|
||||
"contact_data": contact_data,
|
||||
},
|
||||
)
|
||||
assert create_c_result.isError is False
|
||||
|
||||
# 4. Verify contact creation
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
assert any(c["vcard_id"] == contact_uid for c in contacts)
|
||||
|
||||
# 5. Delete contact via MCP
|
||||
logger.info(f"Deleting contact {contact_uid} via MCP")
|
||||
delete_c_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_delete_contact",
|
||||
{"addressbook": addressbook_name, "uid": contact_uid},
|
||||
)
|
||||
assert delete_c_result.isError is False
|
||||
|
||||
# 6. Verify contact deletion
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
assert not any(c["vcard_id"] == contact_uid for c in contacts)
|
||||
|
||||
# 7. Delete address book via MCP
|
||||
logger.info(f"Deleting address book {addressbook_name} via MCP")
|
||||
delete_ab_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_delete_addressbook", {"name": addressbook_name}
|
||||
)
|
||||
assert delete_ab_result.isError is False
|
||||
|
||||
# 8. Verify address book deletion
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
assert not any(ab["name"] == addressbook_name for ab in addressbooks)
|
||||
|
||||
finally:
|
||||
# Cleanup in case of failure
|
||||
try:
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Integration tests for Contacts CardDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_list_addressbooks(nc_client: NextcloudClient):
|
||||
"""Test listing available addressbooks."""
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
|
||||
assert isinstance(addressbooks, list)
|
||||
|
||||
if not addressbooks:
|
||||
pytest.skip("No addressbooks available - Contacts app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(addressbooks)} addressbooks")
|
||||
|
||||
# Check structure of addressbooks
|
||||
for addressbook in addressbooks:
|
||||
assert "name" in addressbook
|
||||
assert "display_name" in addressbook
|
||||
assert "getctag" in addressbook
|
||||
|
||||
logger.info(
|
||||
f"Addressbook: {addressbook['name']} - {addressbook['display_name']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_create_and_delete_addressbook(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str
|
||||
):
|
||||
"""Test creating and deleting a basic addressbook."""
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
addressbook_names = [ab["name"] for ab in addressbooks]
|
||||
assert temporary_addressbook in addressbook_names
|
||||
|
||||
|
||||
async def test_list_contacts(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str, temporary_contact: str
|
||||
):
|
||||
"""Test listing contacts in an addressbook."""
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=temporary_addressbook)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert temporary_contact in contact_uids
|
||||
|
||||
|
||||
async def test_full_contact_workflow(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str
|
||||
):
|
||||
"""Test the full workflow of creating, retrieving, and deleting a contact."""
|
||||
addressbook_name = temporary_addressbook
|
||||
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
|
||||
contact_data = {
|
||||
"fn": "Jane Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
"tel": "9876543210",
|
||||
}
|
||||
|
||||
# Create contact
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=contact_data,
|
||||
)
|
||||
|
||||
# Verify contact was created by listing
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert contact_uid in contact_uids
|
||||
|
||||
# Delete contact
|
||||
await nc_client.contacts.delete_contact(
|
||||
addressbook=addressbook_name, uid=contact_uid
|
||||
)
|
||||
|
||||
# Verify contact was deleted
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert contact_uid not in contact_uids
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,187 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError # Import if needed for specific error checks
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is session-scoped in conftest.py
|
||||
# Note: temporary_note fixture is function-scoped in conftest.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# Keep the test_image fixture as it's specific to generating image data
|
||||
@pytest.fixture(scope="module") # Keep module scope if image generation is slow
|
||||
def test_image_data() -> tuple[bytes, str]:
|
||||
"""
|
||||
Generate test image data (bytes) and suggest a filename.
|
||||
Returns (image_bytes, suggested_filename).
|
||||
"""
|
||||
logger.info("Generating test image data in memory.")
|
||||
img = Image.new("RGB", (300, 200), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) # Blue rectangle
|
||||
draw.text(
|
||||
(50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)
|
||||
) # White text
|
||||
|
||||
img_byte_arr = BytesIO()
|
||||
img.save(img_byte_arr, format="PNG")
|
||||
image_bytes = img_byte_arr.getvalue()
|
||||
suggested_filename = "test_image.png"
|
||||
logger.info(f"Generated test image data ({len(image_bytes)} bytes).")
|
||||
return image_bytes, suggested_filename
|
||||
|
||||
|
||||
async def test_note_with_embedded_image(
|
||||
nc_client: NextcloudClient, temporary_note: dict, test_image_data: tuple
|
||||
):
|
||||
"""
|
||||
Tests creating a note, attaching an image, embedding it in the content,
|
||||
and verifying the attachment can be retrieved.
|
||||
"""
|
||||
note_data = temporary_note # Use fixture for note creation/cleanup
|
||||
note_id = note_data["id"]
|
||||
note_etag = note_data["etag"]
|
||||
image_content, suggested_filename = test_image_data # Get image data from fixture
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = (
|
||||
f"test_image_{unique_suffix}.png" # Make filename unique per run
|
||||
)
|
||||
|
||||
# 1. Upload the image as an attachment
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
logger.info(
|
||||
f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..."
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=image_content,
|
||||
category=note_category, # Pass the category
|
||||
mime_type="image/png",
|
||||
)
|
||||
assert upload_response and upload_response.get("status_code") in [201, 204]
|
||||
logger.info(
|
||||
f"Image uploaded successfully (Status: {upload_response.get('status_code')})."
|
||||
)
|
||||
time.sleep(1) # Allow potential processing time
|
||||
|
||||
# 1.1 Verify attachment directory exists via WebDAV PROPFIND
|
||||
logger.info("Directly checking if attachment directory exists in WebDAV")
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
category_path_part = f"{note_category}/" if note_category else ""
|
||||
attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
assert status in [
|
||||
207,
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified attachment directory exists via PROPFIND ({status} received)"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
|
||||
# 2. Update the note content to include the embedded image references
|
||||
updated_content = f"""{note_data["content"]}
|
||||
|
||||
## Image Embedding Test
|
||||
|
||||
### Markdown Syntax
|
||||

|
||||
|
||||
### HTML Syntax
|
||||
<img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="150" />
|
||||
"""
|
||||
logger.info("Updating note content with image references...")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=note_etag, # Use etag from the created note
|
||||
content=updated_content,
|
||||
title=note_data["title"], # Pass required fields
|
||||
category=note_data["category"], # Pass required fields
|
||||
)
|
||||
new_etag = updated_note["etag"]
|
||||
assert new_etag != note_etag
|
||||
logger.info("Note content updated with image references.")
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify the updated note content
|
||||
retrieved_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"]
|
||||
logger.info("Verified image reference exists in updated note content.")
|
||||
|
||||
# 4. Verify the image attachment can be retrieved
|
||||
logger.info(
|
||||
f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..."
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_img_content, mime_type = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=note_category
|
||||
)
|
||||
assert retrieved_img_content == image_content
|
||||
assert mime_type.startswith("image/png")
|
||||
logger.info(
|
||||
"Successfully retrieved and verified image attachment content and mime type."
|
||||
)
|
||||
|
||||
# 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
|
||||
logger.info(
|
||||
f"Manually deleting note ID: {note_id} to verify proper attachment cleanup"
|
||||
)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify note is deleted
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note:
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note.value.response.status_code == 404
|
||||
logger.info(f"Verified note {note_id} deletion (404 received).")
|
||||
|
||||
# 7. Verify attachment directory is deleted via WebDAV PROPFIND
|
||||
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
# Note: The temporary_note fixture will still run its cleanup,
|
||||
# but it will find the note already deleted (404) and handle it gracefully.
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,792 @@
|
||||
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_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"""Test basic MCP server connectivity and list available tools/resources."""
|
||||
|
||||
# List available tools
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
logger.info("Available MCP tools:")
|
||||
tool_names = []
|
||||
for tool in tools.tools:
|
||||
logger.info(f" - {tool.name}: {tool.description}")
|
||||
tool_names.append(tool.name)
|
||||
|
||||
# Verify expected tools are present
|
||||
expected_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_append_content",
|
||||
"nc_notes_search_notes",
|
||||
"nc_notes_delete_note",
|
||||
"nc_tables_list_tables",
|
||||
"nc_tables_get_schema",
|
||||
"nc_tables_read_table",
|
||||
"nc_tables_insert_row",
|
||||
"nc_tables_update_row",
|
||||
"nc_tables_delete_row",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
"nc_webdav_delete_resource",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_list_events",
|
||||
"nc_calendar_get_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_calendar_create_meeting",
|
||||
"nc_calendar_get_upcoming_events",
|
||||
"nc_calendar_find_availability",
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
"deck_create_board",
|
||||
]
|
||||
|
||||
for expected_tool in expected_tools:
|
||||
assert expected_tool in tool_names, (
|
||||
f"Expected tool '{expected_tool}' not found in available tools"
|
||||
)
|
||||
|
||||
# List available resource templates
|
||||
templates = await nc_mcp_client.list_resource_templates()
|
||||
logger.info("\nAvailable resource templates:")
|
||||
template_uris = []
|
||||
for template in templates.resourceTemplates:
|
||||
logger.info(f" - {template.uriTemplate}")
|
||||
template_uris.append(template.uriTemplate)
|
||||
|
||||
# Verify expected resource templates
|
||||
expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"]
|
||||
|
||||
for expected_template in expected_templates:
|
||||
assert expected_template in template_uris, (
|
||||
f"Expected template '{expected_template}' not found"
|
||||
)
|
||||
|
||||
# List available resources
|
||||
resources = await nc_mcp_client.list_resources()
|
||||
logger.info("\nAvailable resources:")
|
||||
resource_uris = []
|
||||
for resource in resources.resources:
|
||||
logger.info(f" - {resource.uri}: {resource.name}")
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
|
||||
|
||||
for expected_resource in expected_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
f"Expected resource '{expected_resource}' not found"
|
||||
)
|
||||
|
||||
# List available prompts
|
||||
prompts = await nc_mcp_client.list_prompts()
|
||||
logger.info("\nAvailable prompts:")
|
||||
for prompt in prompts.prompts:
|
||||
logger.info(f" - {prompt.name}")
|
||||
|
||||
|
||||
async def test_mcp_notes_crud_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Notes CRUD workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_title = f"MCP Test Note {unique_suffix}"
|
||||
test_content = f"This is test content for note {unique_suffix}"
|
||||
test_category = "MCPTesting"
|
||||
|
||||
created_note = None
|
||||
|
||||
try:
|
||||
# 1. Create note via MCP
|
||||
logger.info(f"Creating note via MCP: {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, (
|
||||
f"MCP note creation failed: {create_result.content}"
|
||||
)
|
||||
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}, 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)
|
||||
assert direct_note["title"] == test_title, (
|
||||
f"Title mismatch: {direct_note['title']} != {test_title}"
|
||||
)
|
||||
assert direct_note["content"] == test_content, "Content mismatch"
|
||||
assert direct_note["category"] == test_category, "Category mismatch"
|
||||
|
||||
# 3. Read note via MCP
|
||||
logger.info(f"Reading note via MCP: {note_id}")
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}")
|
||||
assert len(read_result.contents) == 1, "Expected exactly one content item"
|
||||
read_note_data = json.loads(read_result.contents[0].text)
|
||||
|
||||
assert read_note_data["title"] == test_title
|
||||
assert read_note_data["content"] == test_content
|
||||
assert read_note_data["category"] == test_category
|
||||
|
||||
# 4. Update note via MCP
|
||||
updated_title = f"Updated {test_title}"
|
||||
updated_content = f"Updated content: {test_content}"
|
||||
etag = read_note_data["etag"]
|
||||
|
||||
logger.info(f"Updating note via MCP: {note_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"etag": etag,
|
||||
"title": updated_title,
|
||||
"content": updated_content,
|
||||
"category": test_category,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
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
|
||||
assert updated_direct_note["content"] == updated_content
|
||||
|
||||
# 6. Append content via MCP
|
||||
append_content = "\n\nThis is appended content via MCP."
|
||||
logger.info(f"Appending content to note via MCP: {note_id}")
|
||||
append_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_append_content", {"note_id": note_id, "content": append_content}
|
||||
)
|
||||
|
||||
assert append_result.isError is False, (
|
||||
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"]
|
||||
|
||||
# 8. Search for note via MCP
|
||||
logger.info(f"Searching for note via MCP with query: {unique_suffix}")
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", {"query": unique_suffix}
|
||||
)
|
||||
|
||||
assert search_result.isError is False, (
|
||||
f"MCP note search failed: {search_result.content}"
|
||||
)
|
||||
search_notes_text = search_result.content[0].text
|
||||
logger.info(f"Search result text: {search_notes_text}")
|
||||
search_response = json.loads(search_notes_text)
|
||||
|
||||
# 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
|
||||
for note in search_notes:
|
||||
if isinstance(note, dict) and note.get("id") == note_id:
|
||||
found_note = note
|
||||
break
|
||||
|
||||
assert found_note is not None, (
|
||||
f"Created note not found in search results. Search returned: {search_response}"
|
||||
)
|
||||
assert found_note["title"] == updated_title
|
||||
|
||||
# 9. Delete note via MCP
|
||||
logger.info(f"Deleting note via MCP: {note_id}")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, (
|
||||
f"MCP note deletion failed: {delete_result.content}"
|
||||
)
|
||||
|
||||
# 10. Verify deletion via direct NextcloudClient
|
||||
try:
|
||||
await nc_client.notes.get_note(note_id)
|
||||
pytest.fail("Note should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - note should be deleted
|
||||
logger.info(f"Successfully verified note {note_id} was deleted")
|
||||
created_note = None # Mark as cleaned up
|
||||
|
||||
finally:
|
||||
# Cleanup in case of test failure
|
||||
if created_note is not None:
|
||||
try:
|
||||
note_data = json.loads(created_note)
|
||||
await nc_client.notes.delete_note(note_data["id"])
|
||||
logger.info(f"Cleaned up note {note_data['id']} after test failure")
|
||||
except Exception as e:
|
||||
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
|
||||
):
|
||||
"""Test WebDAV file operations via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_dir = f"mcp_test_dir_{unique_suffix}"
|
||||
test_file = f"mcp_test_file_{unique_suffix}.txt"
|
||||
test_file_path = f"{test_dir}/{test_file}"
|
||||
test_content = f"This is test content for MCP WebDAV testing {unique_suffix}"
|
||||
|
||||
try:
|
||||
# 1. Create directory via MCP
|
||||
logger.info(f"Creating directory via MCP: {test_dir}")
|
||||
create_dir_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_create_directory", {"path": test_dir}
|
||||
)
|
||||
|
||||
assert create_dir_result.isError is False, (
|
||||
f"MCP directory creation failed: {create_dir_result.content}"
|
||||
)
|
||||
|
||||
# 2. Verify directory creation via direct WebDAV
|
||||
dir_listing = await nc_client.webdav.list_directory("")
|
||||
dir_names = [item["name"] for item in dir_listing if item["is_directory"]]
|
||||
assert test_dir in dir_names, f"Directory {test_dir} not found in root listing"
|
||||
|
||||
# 3. Write file via MCP
|
||||
logger.info(f"Writing file via MCP: {test_file_path}")
|
||||
write_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
{
|
||||
"path": test_file_path,
|
||||
"content": test_content,
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
)
|
||||
|
||||
assert write_result.isError is False, (
|
||||
f"MCP file write failed: {write_result.content}"
|
||||
)
|
||||
|
||||
# 4. Verify file creation via direct WebDAV
|
||||
file_listing = await nc_client.webdav.list_directory(test_dir)
|
||||
file_names = [item["name"] for item in file_listing if not item["is_directory"]]
|
||||
assert test_file in file_names, (
|
||||
f"File {test_file} not found in directory listing"
|
||||
)
|
||||
|
||||
# 5. Read file via MCP
|
||||
logger.info(f"Reading file via MCP: {test_file_path}")
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", {"path": test_file_path}
|
||||
)
|
||||
|
||||
assert read_result.isError is False, (
|
||||
f"MCP file read failed: {read_result.content}"
|
||||
)
|
||||
read_data = json.loads(read_result.content[0].text)
|
||||
|
||||
assert read_data["content"] == test_content, "File content mismatch"
|
||||
assert read_data["path"] == test_file_path
|
||||
assert "text/plain" in read_data["content_type"]
|
||||
|
||||
# 6. Verify file content via direct WebDAV
|
||||
direct_content, direct_content_type = await nc_client.webdav.read_file(
|
||||
test_file_path
|
||||
)
|
||||
assert direct_content.decode("utf-8") == test_content
|
||||
|
||||
# 7. List directory via MCP
|
||||
logger.info(f"Listing directory via MCP: {test_dir}")
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory", {"path": test_dir}
|
||||
)
|
||||
|
||||
assert list_result.isError is False, (
|
||||
f"MCP directory listing failed: {list_result.content}"
|
||||
)
|
||||
listing_text = list_result.content[0].text
|
||||
logger.info(f"Directory listing response: {listing_text}")
|
||||
listing_data = json.loads(listing_text)
|
||||
|
||||
# Ensure listing_data is a list
|
||||
if not isinstance(listing_data, list):
|
||||
logger.warning(
|
||||
f"Expected directory listing to be a list, got: {type(listing_data)}"
|
||||
)
|
||||
listing_data = [listing_data] if listing_data else []
|
||||
|
||||
# Find our file in the listing
|
||||
found_file = None
|
||||
for item in listing_data:
|
||||
if isinstance(item, dict) and item.get("name") == test_file:
|
||||
found_file = item
|
||||
break
|
||||
|
||||
assert found_file is not None, (
|
||||
f"File {test_file} not found in MCP directory listing"
|
||||
)
|
||||
assert found_file["is_directory"] is False
|
||||
assert found_file["size"] == len(test_content.encode("utf-8"))
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
logger.info(f"Cleaning up test file: {test_file_path}")
|
||||
await nc_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": test_file_path}
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up test directory: {test_dir}")
|
||||
await nc_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": test_dir}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup WebDAV resources: {e}")
|
||||
|
||||
|
||||
async def test_mcp_resources_access(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test accessing MCP resources and compare with direct API calls."""
|
||||
|
||||
# 1. Test capabilities resource
|
||||
logger.info("Testing capabilities resource via MCP")
|
||||
caps_result = await nc_mcp_client.read_resource("nc://capabilities")
|
||||
assert len(caps_result.contents) == 1
|
||||
mcp_capabilities = json.loads(caps_result.contents[0].text)
|
||||
|
||||
# Compare with direct API call
|
||||
direct_capabilities = await nc_client.capabilities()
|
||||
|
||||
# Basic validation - both should have similar structure
|
||||
# Both return full OCS response structure
|
||||
assert "ocs" in mcp_capabilities
|
||||
assert "data" in mcp_capabilities["ocs"]
|
||||
assert "version" in mcp_capabilities["ocs"]["data"]
|
||||
assert "ocs" in direct_capabilities
|
||||
assert "data" in direct_capabilities["ocs"]
|
||||
assert "version" in direct_capabilities["ocs"]["data"]
|
||||
|
||||
# 2. Test notes settings resource
|
||||
logger.info("Testing notes settings resource via MCP")
|
||||
settings_result = await nc_mcp_client.read_resource("notes://settings")
|
||||
assert len(settings_result.contents) == 1
|
||||
mcp_settings = json.loads(settings_result.contents[0].text)
|
||||
|
||||
# Compare with direct API call
|
||||
direct_settings = await nc_client.notes.get_settings()
|
||||
|
||||
# Both should have settings data
|
||||
assert isinstance(mcp_settings, dict)
|
||||
assert isinstance(direct_settings, dict)
|
||||
|
||||
logger.info("Successfully verified MCP resources match direct API calls")
|
||||
|
||||
|
||||
async def test_mcp_calendar_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Calendar workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_event_title = f"MCP Test Event {unique_suffix}"
|
||||
test_location = f"MCP Test Location {unique_suffix}"
|
||||
|
||||
created_event = None
|
||||
calendar_name = None
|
||||
|
||||
try:
|
||||
# 1. List calendars via MCP
|
||||
logger.info("Listing calendars via MCP")
|
||||
calendars_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_calendars", {}
|
||||
)
|
||||
|
||||
assert calendars_result.isError is False, (
|
||||
f"MCP calendar listing failed: {calendars_result.content}"
|
||||
)
|
||||
|
||||
calendars_response = json.loads(calendars_result.content[0].text)
|
||||
|
||||
# Debug output to understand the structure
|
||||
logger.info(f"calendars_response type: {type(calendars_response)}")
|
||||
logger.info(f"calendars_response content: {calendars_response}")
|
||||
|
||||
# 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
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
start_datetime = tomorrow.strftime("%Y-%m-%dT14:00:00")
|
||||
end_datetime = tomorrow.strftime("%Y-%m-%dT15:00:00")
|
||||
|
||||
event_data = {
|
||||
"calendar_name": calendar_name,
|
||||
"title": test_event_title,
|
||||
"start_datetime": start_datetime,
|
||||
"end_datetime": end_datetime,
|
||||
"description": f"Test event created via MCP {unique_suffix}",
|
||||
"location": test_location,
|
||||
"categories": "testing,mcp",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
logger.info(f"Creating event via MCP: {test_event_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event", event_data
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP event creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
created_event_data = json.loads(create_result.content[0].text)
|
||||
event_uid = created_event_data["uid"]
|
||||
created_event = {"uid": event_uid, "calendar_name": calendar_name}
|
||||
|
||||
logger.info(f"Event created via MCP with UID: {event_uid}")
|
||||
|
||||
# 3. Verify creation via direct NextcloudClient
|
||||
direct_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert direct_event["title"] == test_event_title
|
||||
assert direct_event["location"] == test_location
|
||||
assert "testing" in direct_event.get("categories", "")
|
||||
|
||||
# 4. Get event via MCP
|
||||
logger.info(f"Getting event via MCP: {event_uid}")
|
||||
get_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_get_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
)
|
||||
|
||||
assert get_result.isError is False, (
|
||||
f"MCP event get failed: {get_result.content}"
|
||||
)
|
||||
|
||||
get_event_data = json.loads(get_result.content[0].text)
|
||||
assert get_event_data["title"] == test_event_title
|
||||
assert get_event_data["location"] == test_location
|
||||
|
||||
# 5. **TEST nc_calendar_list_events - This is the main tool we're testing**
|
||||
logger.info("Testing nc_calendar_list_events via MCP")
|
||||
|
||||
# Get today and next week for date range
|
||||
today = datetime.now()
|
||||
next_week = today + timedelta(days=7)
|
||||
start_date = today.strftime("%Y-%m-%d")
|
||||
end_date = next_week.strftime("%Y-%m-%d")
|
||||
|
||||
list_events_data = {
|
||||
"calendar_name": calendar_name,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"limit": 50,
|
||||
"location_contains": "MCP Test",
|
||||
"title_contains": unique_suffix,
|
||||
}
|
||||
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_events", list_events_data
|
||||
)
|
||||
|
||||
assert list_result.isError is False, (
|
||||
f"MCP list events failed: {list_result.content}"
|
||||
)
|
||||
|
||||
events_data = json.loads(list_result.content[0].text)
|
||||
|
||||
# Debug output to understand what nc_calendar_list_events returns
|
||||
logger.info(f"list_events result type: {type(events_data)}")
|
||||
logger.info(f"list_events result content: {events_data}")
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
events_data = [events_data]
|
||||
|
||||
assert isinstance(events_data, list), "Expected events list"
|
||||
|
||||
# Our created event should be in the list
|
||||
found_event = None
|
||||
for event in events_data:
|
||||
if event.get("uid") == event_uid:
|
||||
found_event = event
|
||||
break
|
||||
|
||||
assert found_event is not None, (
|
||||
f"Created event {event_uid} not found in events list"
|
||||
)
|
||||
assert found_event["title"] == test_event_title
|
||||
|
||||
# 6. Test list events across all calendars
|
||||
logger.info("Testing nc_calendar_list_events across all calendars")
|
||||
|
||||
all_calendars_data = {
|
||||
"calendar_name": "", # Will be ignored
|
||||
"search_all_calendars": True,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"title_contains": unique_suffix,
|
||||
}
|
||||
|
||||
all_list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_events", all_calendars_data
|
||||
)
|
||||
|
||||
assert all_list_result.isError is False, (
|
||||
f"MCP list all events failed: {all_list_result.content}"
|
||||
)
|
||||
|
||||
all_events_data = json.loads(all_list_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(all_events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
all_events_data = [all_events_data]
|
||||
|
||||
assert isinstance(all_events_data, list), "Expected events list"
|
||||
|
||||
# Our event should still be found when searching all calendars
|
||||
found_in_all = any(event.get("uid") == event_uid for event in all_events_data)
|
||||
assert found_in_all, "Event not found when searching all calendars"
|
||||
|
||||
# 7. Update event via MCP
|
||||
updated_title = f"Updated {test_event_title}"
|
||||
updated_description = f"Updated description {unique_suffix}"
|
||||
|
||||
update_data = {
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"title": updated_title,
|
||||
"description": updated_description,
|
||||
"priority": 1,
|
||||
}
|
||||
|
||||
logger.info(f"Updating event via MCP: {event_uid}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event", update_data
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP event update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 8. Verify update via direct NextcloudClient
|
||||
updated_direct_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert updated_direct_event["title"] == updated_title
|
||||
assert updated_direct_event["description"] == updated_description
|
||||
assert updated_direct_event["priority"] == 1
|
||||
|
||||
# 9. Test upcoming events via MCP
|
||||
logger.info("Testing nc_calendar_get_upcoming_events via MCP")
|
||||
upcoming_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_get_upcoming_events",
|
||||
{"calendar_name": calendar_name, "days_ahead": 7, "limit": 10},
|
||||
)
|
||||
|
||||
assert upcoming_result.isError is False, (
|
||||
f"MCP upcoming events failed: {upcoming_result.content}"
|
||||
)
|
||||
|
||||
upcoming_events = json.loads(upcoming_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as other tools)
|
||||
if isinstance(upcoming_events, dict):
|
||||
# Single event returned as dict instead of list
|
||||
upcoming_events = [upcoming_events]
|
||||
|
||||
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||
|
||||
# 10. Delete event via MCP
|
||||
logger.info(f"Deleting event via MCP: {event_uid}")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_delete_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, (
|
||||
f"MCP event deletion failed: {delete_result.content}"
|
||||
)
|
||||
|
||||
# 11. Verify deletion via direct NextcloudClient
|
||||
try:
|
||||
await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
pytest.fail("Event should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - event should be deleted
|
||||
logger.info(f"Successfully verified event {event_uid} was deleted")
|
||||
created_event = None # Mark as cleaned up
|
||||
|
||||
except Exception as e:
|
||||
if "Calendar app may not be enabled" in str(
|
||||
e
|
||||
) or "No calendars available" in str(e):
|
||||
pytest.skip("Calendar functionality not available for testing")
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Cleanup in case of test failure
|
||||
if created_event is not None:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(
|
||||
created_event["calendar_name"], created_event["uid"]
|
||||
)
|
||||
logger.info(
|
||||
f"Cleaned up event {created_event['uid']} after test failure"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup event: {e}")
|
||||
@@ -0,0 +1,260 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is now session-scoped in conftest.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
original_category = created_note_data["category"]
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
content="First update content",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
# Create an empty note
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
try:
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
first_append = f"First append {uuid.uuid4().hex[:8]}"
|
||||
second_append = f"Second append {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
)
|
||||
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
@@ -0,0 +1,535 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
This assumes that the sample table exists in the Nextcloud instance.
|
||||
"""
|
||||
logger.info("Looking for sample table in Nextcloud Tables app")
|
||||
|
||||
# Get all tables
|
||||
tables = await nc_client.tables.list_tables()
|
||||
|
||||
# Look for a sample table (usually created by default)
|
||||
sample_table = None
|
||||
for table in tables:
|
||||
# Common names for sample tables
|
||||
if any(
|
||||
keyword in table.get("title", "").lower()
|
||||
for keyword in ["sample", "demo", "example", "test"]
|
||||
):
|
||||
sample_table = table
|
||||
break
|
||||
|
||||
if not sample_table and tables:
|
||||
# If no sample table found, use the first available table
|
||||
sample_table = tables[0]
|
||||
logger.info(
|
||||
f"No sample table found, using first available table: {sample_table.get('title')}"
|
||||
)
|
||||
|
||||
if not sample_table:
|
||||
pytest.skip(
|
||||
"No tables found in Nextcloud Tables app. Please ensure Tables app is installed and has at least one table."
|
||||
)
|
||||
|
||||
# Get the schema for the sample table
|
||||
table_id = sample_table["id"]
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
logger.info(f"Using sample table: {sample_table.get('title')} (ID: {table_id})")
|
||||
|
||||
return {
|
||||
"table": sample_table,
|
||||
"schema": schema,
|
||||
"table_id": table_id,
|
||||
"columns": schema.get("columns", []),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_table_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary row in the sample table for testing.
|
||||
Yields the created row data and cleans up afterward.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 42
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating temporary row in table {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row.get("id")
|
||||
|
||||
if not row_id:
|
||||
pytest.fail("Failed to get ID from created temporary row.")
|
||||
|
||||
logger.info(f"Temporary row created with ID: {row_id}")
|
||||
yield created_row
|
||||
|
||||
finally:
|
||||
if created_row and created_row.get("id"):
|
||||
row_id = created_row["id"]
|
||||
logger.info(f"Cleaning up temporary row ID: {row_id}")
|
||||
try:
|
||||
await nc_client.tables.delete_row(row_id)
|
||||
logger.info(f"Successfully deleted temporary row ID: {row_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if row was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary row {row_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary row {row_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary row {row_id}: {e}")
|
||||
|
||||
|
||||
async def test_tables_list_tables(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all tables available to the user.
|
||||
"""
|
||||
logger.info("Testing list_tables functionality")
|
||||
|
||||
tables = await nc_client.tables.list_tables()
|
||||
|
||||
assert isinstance(tables, list)
|
||||
assert len(tables) > 0, "Expected at least one table to be available"
|
||||
|
||||
# Check that each table has required fields
|
||||
for table in tables:
|
||||
assert "id" in table
|
||||
assert "title" in table
|
||||
assert isinstance(table["id"], int)
|
||||
assert isinstance(table["title"], str)
|
||||
|
||||
logger.info(f"Successfully listed {len(tables)} tables")
|
||||
|
||||
|
||||
async def test_tables_get_schema(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test getting the schema/structure of a specific table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
|
||||
logger.info(f"Testing get_table_schema for table ID: {table_id}")
|
||||
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
assert "columns" in schema
|
||||
assert isinstance(schema["columns"], list)
|
||||
assert len(schema["columns"]) > 0, "Expected at least one column in the table"
|
||||
|
||||
# Check that each column has required fields
|
||||
for column in schema["columns"]:
|
||||
assert "id" in column
|
||||
assert "title" in column
|
||||
assert "type" in column
|
||||
assert isinstance(column["id"], int)
|
||||
assert isinstance(column["title"], str)
|
||||
assert isinstance(column["type"], str)
|
||||
|
||||
logger.info(f"Successfully retrieved schema with {len(schema['columns'])} columns")
|
||||
|
||||
|
||||
async def test_tables_read_table(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test reading rows from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
|
||||
logger.info(f"Testing get_table_rows for table ID: {table_id}")
|
||||
|
||||
# Test without pagination
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
assert isinstance(rows, list)
|
||||
# Note: The table might be empty, so we don't assert len > 0
|
||||
|
||||
# Test with pagination
|
||||
rows_limited = await nc_client.tables.get_table_rows(table_id, limit=5, offset=0)
|
||||
|
||||
assert isinstance(rows_limited, list)
|
||||
assert len(rows_limited) <= 5
|
||||
|
||||
# If there are rows, check their structure
|
||||
if rows:
|
||||
row = rows[0]
|
||||
assert "id" in row
|
||||
assert "tableId" in row
|
||||
assert "data" in row
|
||||
assert isinstance(row["id"], int)
|
||||
assert isinstance(row["tableId"], int)
|
||||
assert isinstance(row["data"], list)
|
||||
|
||||
logger.info(f"Successfully read {len(rows)} rows from table")
|
||||
|
||||
|
||||
async def test_tables_create_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test creating a new row in a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 123
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
|
||||
assert isinstance(created_row, dict)
|
||||
assert "id" in created_row
|
||||
assert "tableId" in created_row
|
||||
assert isinstance(created_row["id"], int)
|
||||
assert created_row["tableId"] == table_id
|
||||
|
||||
# Verify the row was created by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
created_row_id = created_row["id"]
|
||||
|
||||
# Find the created row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == created_row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, (
|
||||
f"Created row with ID {created_row_id} not found in table"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created row with ID: {created_row_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the created row
|
||||
if created_row and created_row.get("id"):
|
||||
try:
|
||||
await nc_client.tables.delete_row(created_row["id"])
|
||||
logger.info(f"Cleaned up created row ID: {created_row['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up created row: {e}")
|
||||
|
||||
|
||||
async def test_tables_update_row(
|
||||
nc_client: NextcloudClient,
|
||||
temporary_table_row: Dict[str, Any],
|
||||
sample_table_info: Dict[str, Any],
|
||||
):
|
||||
"""
|
||||
Test updating an existing row in a table.
|
||||
"""
|
||||
row_id = temporary_table_row["id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create updated data
|
||||
update_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate updated test data based on column type
|
||||
if column_type == "text":
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
update_data[column_id] = 456
|
||||
elif column_type == "datetime":
|
||||
update_data[column_id] = "2024-12-31T23:59:59Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
update_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
update_data[column_id] = "Updated Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Testing update_row for row ID: {row_id} with data: {update_data}")
|
||||
|
||||
updated_row = await nc_client.tables.update_row(row_id, update_data)
|
||||
|
||||
assert isinstance(updated_row, dict)
|
||||
assert "id" in updated_row
|
||||
assert updated_row["id"] == row_id
|
||||
|
||||
# Verify the row was updated by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
table_id = sample_table_info["table_id"]
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Find the updated row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, f"Updated row with ID {row_id} not found in table"
|
||||
|
||||
logger.info(f"Successfully updated row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test deleting a row from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# First create a row to delete
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 789
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-06-15T10:30:00Z"
|
||||
elif column_type == "select":
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Delete Option"
|
||||
else:
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating row for delete test in table ID: {table_id}")
|
||||
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row["id"]
|
||||
|
||||
logger.info(f"Testing delete_row for row ID: {row_id}")
|
||||
|
||||
# Delete the row
|
||||
delete_result = await nc_client.tables.delete_row(row_id)
|
||||
|
||||
assert isinstance(delete_result, dict)
|
||||
# The delete response might vary, but it should be successful
|
||||
|
||||
# Verify the row was deleted by trying to find it
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Ensure the deleted row is not in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is None, f"Deleted row with ID {row_id} still found in table"
|
||||
|
||||
logger.info(f"Successfully deleted row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that deleting a non-existent row fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing delete_row for non-existent row ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.delete_row(non_existent_id)
|
||||
|
||||
# Accept both 404 and 500 as valid error responses for non-existent rows
|
||||
# The API behavior may vary between Nextcloud versions
|
||||
assert excinfo.value.response.status_code in [404, 500]
|
||||
logger.info(
|
||||
f"Deleting non-existent row ID: {non_existent_id} correctly failed with {excinfo.value.response.status_code}."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_transform_row_data(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test the transform_row_data utility method.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
logger.info(f"Testing transform_row_data for table ID: {table_id}")
|
||||
|
||||
# Get some rows to transform
|
||||
rows = await nc_client.tables.get_table_rows(table_id, limit=5)
|
||||
|
||||
if not rows:
|
||||
logger.info("No rows to transform, skipping transform_row_data test")
|
||||
return
|
||||
|
||||
# Transform the rows
|
||||
transformed_rows = nc_client.tables.transform_row_data(rows, columns)
|
||||
|
||||
assert isinstance(transformed_rows, list)
|
||||
assert len(transformed_rows) == len(rows)
|
||||
|
||||
# Check the structure of transformed rows
|
||||
for i, transformed_row in enumerate(transformed_rows):
|
||||
original_row = rows[i]
|
||||
|
||||
assert "id" in transformed_row
|
||||
assert "tableId" in transformed_row
|
||||
assert "data" in transformed_row
|
||||
assert transformed_row["id"] == original_row["id"]
|
||||
assert transformed_row["tableId"] == original_row["tableId"]
|
||||
assert isinstance(transformed_row["data"], dict)
|
||||
|
||||
# Check that column IDs were transformed to column names
|
||||
for column in columns:
|
||||
column_title = column["title"]
|
||||
# The transformed data should have column names as keys
|
||||
# (though the column might not have data in this row)
|
||||
if any(item["columnId"] == column["id"] for item in original_row["data"]):
|
||||
assert column_title in transformed_row["data"]
|
||||
|
||||
logger.info(f"Successfully transformed {len(transformed_rows)} rows")
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_table_schema(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that getting schema for a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(
|
||||
f"Testing get_table_schema for non-existent table ID: {non_existent_id}"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_schema(non_existent_id)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Getting schema for non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_read_nonexistent_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that reading from a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing get_table_rows for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_rows(non_existent_id)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Reading from non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_create_row_invalid_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that creating a row in a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
test_data = {1: "test value"}
|
||||
|
||||
logger.info(f"Testing create_row for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.create_row(non_existent_id, test_data)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_category_change_cleans_up_old_attachments_directory(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""
|
||||
Tests that when a note's category is changed, the old attachment directory is properly cleaned up.
|
||||
"""
|
||||
note_id = None
|
||||
initial_category = "CategoryTest1"
|
||||
new_category = "CategoryTest2"
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Category Cleanup Test {unique_suffix}"
|
||||
attachment_filename = f"cleanup_test_{unique_suffix}.txt"
|
||||
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
||||
|
||||
try:
|
||||
# 1. Create note with initial category
|
||||
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content="Initial content", category=initial_category
|
||||
)
|
||||
note_id = created_note["id"]
|
||||
etag1 = created_note["etag"]
|
||||
logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Add attachment (passing initial category)
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=attachment_content,
|
||||
category=initial_category,
|
||||
mime_type="text/plain",
|
||||
)
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info("Attachment added successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify attachment retrieval from initial category
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
||||
)
|
||||
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
assert retrieved_content1 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from initial category.")
|
||||
|
||||
# 4. Construct and check the WebDAV path for the initial category's attachment directory
|
||||
initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}"
|
||||
logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}")
|
||||
# Here we would check if the directory exists, but the WebDAV client doesn't directly
|
||||
# expose directory listing functionality, so we'll infer from attachment retrieval success
|
||||
|
||||
# 5. Update note category
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=current_etag,
|
||||
category=new_category,
|
||||
title=note_title,
|
||||
content="Updated content",
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
logger.info(f"Note category updated successfully. New Etag: {etag3}")
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify attachment retrieval from new category
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from new category '{new_category}'"
|
||||
)
|
||||
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=new_category
|
||||
)
|
||||
assert retrieved_content2 == attachment_content
|
||||
logger.info("Attachment retrieved successfully from new category.")
|
||||
|
||||
# 7. Try to retrieve from old category - this should fail
|
||||
logger.info(
|
||||
f"Trying to retrieve attachment from old category '{initial_category}' - should fail"
|
||||
)
|
||||
try:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
# If we get here, it means the old directory still exists (a problem)
|
||||
logger.error(
|
||||
"ISSUE DETECTED: Was able to retrieve attachment from old category path!"
|
||||
)
|
||||
assert False, (
|
||||
"Old category attachment directory still exists and accessible!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# This is the expected outcome - old directory should be gone
|
||||
logger.info(
|
||||
f"Correctly got error accessing old category path: {e.response.status_code}"
|
||||
)
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is not accessible (good!)"
|
||||
)
|
||||
|
||||
# 7.1 Directly check old attachment directory existence using WebDAV PROPFIND
|
||||
logger.info(
|
||||
"Directly checking if old attachment directory exists in WebDAV"
|
||||
)
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified old attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# 404 is expected - directory should not exist
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old attachment directory does not exist via PROPFIND (404 received)"
|
||||
)
|
||||
|
||||
finally:
|
||||
# 8. Cleanup: Delete the note
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up note ID: {note_id}")
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
time.sleep(1)
|
||||
|
||||
# 9. Verify both old and new attachment paths are gone
|
||||
logger.info("Verifying all attachment paths are gone")
|
||||
with pytest.raises(HTTPStatusError) as excinfo_new:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=new_category,
|
||||
)
|
||||
assert excinfo_new.value.response.status_code == 404
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo_old:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=initial_category,
|
||||
)
|
||||
assert excinfo_old.value.response.status_code == 404
|
||||
|
||||
# 9.1 Directly verify directories don't exist using WebDAV PROPFIND
|
||||
logger.info(
|
||||
"Directly verifying attachment directories don't exist via PROPFIND"
|
||||
)
|
||||
webdav_base = nc_client._get_webdav_base_path()
|
||||
|
||||
# Check new category attachment directory
|
||||
new_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
||||
)
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"New category attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified new category attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified new category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
# Check old category attachment directory
|
||||
old_attachment_dir_path = (
|
||||
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
||||
)
|
||||
try:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old category attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified old category attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is gone via PROPFIND"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Verified all attachment directories are properly cleaned up."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup for note {note_id}: {e}")
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Integration tests for WebDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_base_path(nc_client: NextcloudClient):
|
||||
"""Base path for test files/directories."""
|
||||
test_dir = f"mcp_test_{uuid.uuid4().hex[:8]}"
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
yield test_dir
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
|
||||
|
||||
async def test_create_and_delete_directory(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating and deleting directories."""
|
||||
test_dir = f"{test_base_path}/test_directory"
|
||||
|
||||
try:
|
||||
# Create directory
|
||||
result = await nc_client.webdav.create_directory(test_dir)
|
||||
assert result["status_code"] == 201 # Created
|
||||
logger.info(f"Created directory: {test_dir}")
|
||||
|
||||
# Verify directory exists by listing parent
|
||||
parent_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
dir_names = [item["name"] for item in parent_listing]
|
||||
assert "test_directory" in dir_names
|
||||
|
||||
# Delete directory
|
||||
delete_result = await nc_client.webdav.delete_resource(test_dir)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted directory: {test_dir}")
|
||||
|
||||
finally:
|
||||
# Cleanup: ensure directory is deleted
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test writing, reading, and deleting files."""
|
||||
test_file = f"{test_base_path}/test_file.txt"
|
||||
test_content = f"Test content {uuid.uuid4().hex}"
|
||||
|
||||
try:
|
||||
# Create base directory first
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write file
|
||||
write_result = await nc_client.webdav.write_file(
|
||||
test_file, test_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert write_result["status_code"] in [200, 201, 204] # Success codes
|
||||
logger.info(f"Wrote file: {test_file}")
|
||||
|
||||
# Read file back
|
||||
content, content_type = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == test_content
|
||||
assert "text/plain" in content_type
|
||||
logger.info(f"Read file: {test_file}")
|
||||
|
||||
# Verify file appears in directory listing
|
||||
listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
file_names = [item["name"] for item in listing]
|
||||
assert "test_file.txt" in file_names
|
||||
|
||||
# Delete file
|
||||
delete_result = await nc_client.webdav.delete_resource(test_file)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_directory_empty_and_populated(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test listing empty and populated directories."""
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# List empty directory
|
||||
empty_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert isinstance(empty_listing, list)
|
||||
assert len(empty_listing) == 0
|
||||
logger.info(f"Empty directory listing: {len(empty_listing)} items")
|
||||
|
||||
# Add some files and directories
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file1.txt", b"content1", content_type="text/plain"
|
||||
)
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file2.md",
|
||||
b"# Markdown content",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
# List populated directory
|
||||
populated_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert len(populated_listing) == 4 # 2 dirs + 2 files
|
||||
|
||||
# Check that we have both files and directories
|
||||
names = [item["name"] for item in populated_listing]
|
||||
assert "subdir1" in names
|
||||
assert "subdir2" in names
|
||||
assert "file1.txt" in names
|
||||
assert "file2.md" in names
|
||||
|
||||
# Check metadata is present
|
||||
for item in populated_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Populated directory listing: {len(populated_listing)} items")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file1.txt")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file2.md")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_read_nonexistent_file(nc_client: NextcloudClient):
|
||||
"""Test reading a file that doesn't exist."""
|
||||
nonexistent_file = f"nonexistent_{uuid.uuid4().hex}.txt"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.webdav.read_file(nonexistent_file)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
"""Test deleting a resource that doesn't exist."""
|
||||
nonexistent_resource = f"nonexistent_{uuid.uuid4().hex}"
|
||||
|
||||
result = await nc_client.webdav.delete_resource(nonexistent_resource)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}")
|
||||
|
||||
|
||||
async def test_create_nested_directories(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating nested directory structures."""
|
||||
nested_path = f"{test_base_path}/level1/level2/level3"
|
||||
|
||||
try:
|
||||
# Create nested directories (should create parent directories automatically)
|
||||
result = await nc_client.webdav.create_directory(nested_path, True)
|
||||
assert result["status_code"] == 201
|
||||
|
||||
# Verify the structure was created
|
||||
level1_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1"
|
||||
)
|
||||
assert len(level1_listing) == 1
|
||||
assert level1_listing[0]["name"] == "level2"
|
||||
assert level1_listing[0]["is_directory"] is True
|
||||
|
||||
level2_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1/level2"
|
||||
)
|
||||
assert len(level2_listing) == 1
|
||||
assert level2_listing[0]["name"] == "level3"
|
||||
assert level2_listing[0]["is_directory"] is True
|
||||
|
||||
logger.info(f"Created nested directory structure: {nested_path}")
|
||||
|
||||
finally:
|
||||
# Cleanup - delete from deepest to shallowest
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(nested_path)
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1/level2")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test overwriting an existing file."""
|
||||
test_file = f"{test_base_path}/overwrite_test.txt"
|
||||
original_content = "Original content"
|
||||
new_content = "New content after overwrite"
|
||||
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write original file
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, original_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
|
||||
# Verify original content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == original_content
|
||||
|
||||
# Overwrite with new content
|
||||
overwrite_result = await nc_client.webdav.write_file(
|
||||
test_file, new_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert overwrite_result["status_code"] in [200, 204] # OK or No Content
|
||||
|
||||
# Verify new content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == new_content
|
||||
|
||||
logger.info(f"Successfully overwrote file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_root_directory(nc_client: NextcloudClient):
|
||||
"""Test listing the root directory."""
|
||||
root_listing = await nc_client.webdav.list_directory("")
|
||||
|
||||
# Root directory should exist and be listable
|
||||
assert isinstance(root_listing, list)
|
||||
# Should have at least some default folders/files
|
||||
assert len(root_listing) >= 0
|
||||
|
||||
# Check structure of items
|
||||
for item in root_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Root directory contains {len(root_listing)} items")
|
||||
@@ -1,147 +0,0 @@
|
||||
import pytest
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def nc_client() -> NextcloudClient:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
Reads credentials from environment variables.
|
||||
Scope is 'module' so the client is reused for all tests in this file.
|
||||
"""
|
||||
# Basic check to ensure env vars seem present - tests will fail properly if not
|
||||
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
|
||||
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
|
||||
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
|
||||
return NextcloudClient.from_env()
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_note_crud_integration(nc_client: NextcloudClient):
|
||||
"""
|
||||
Integration test for the complete CRUD (Create, Read, Update, Delete)
|
||||
lifecycle of a note.
|
||||
"""
|
||||
# --- Create ---
|
||||
unique_id = str(uuid.uuid4()) # To ensure note is unique for this test run
|
||||
create_title = f"Integration Test Note {unique_id}"
|
||||
create_content = f"Content for integration test {unique_id}"
|
||||
create_category = "IntegrationTesting"
|
||||
|
||||
created_note = None # Initialize to ensure cleanup happens even if create fails mid-assert
|
||||
try:
|
||||
print(f"\nAttempting to create note: {create_title}")
|
||||
created_note = nc_client.notes_create_note(
|
||||
title=create_title, content=create_content, category=create_category
|
||||
)
|
||||
print(f"Note created: {created_note}")
|
||||
|
||||
assert created_note is not None
|
||||
assert "id" in created_note
|
||||
assert created_note["title"] == create_title
|
||||
assert created_note["content"] == create_content
|
||||
assert created_note["category"] == create_category
|
||||
assert "etag" in created_note
|
||||
note_id = created_note["id"]
|
||||
etag = created_note["etag"]
|
||||
|
||||
# Add a small delay to allow Nextcloud to process if needed
|
||||
time.sleep(1)
|
||||
|
||||
# --- Read (Verify Create) ---
|
||||
print(f"Attempting to read note ID: {note_id}")
|
||||
read_note = nc_client.notes_get_note(note_id=note_id)
|
||||
print(f"Note read: {read_note}")
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == create_title
|
||||
assert read_note["content"] == create_content
|
||||
assert read_note["category"] == create_category
|
||||
# Etag might change even on read in some systems, so don't assert etag equality here
|
||||
|
||||
# --- Update ---
|
||||
update_title = f"Updated Test Note {unique_id}"
|
||||
update_content = f"Updated content {unique_id}"
|
||||
# Use the etag from the *creation* for the update's If-Match header
|
||||
print(f"Attempting to update note ID: {note_id} with etag: {etag}")
|
||||
updated_note = nc_client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=create_category # Keep category same or update if needed
|
||||
)
|
||||
print(f"Note updated: {updated_note}")
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert updated_note["category"] == create_category # Category wasn't updated
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != etag # Etag must change on update
|
||||
new_etag = updated_note["etag"]
|
||||
|
||||
# Add a small delay
|
||||
time.sleep(1)
|
||||
|
||||
# --- Read (Verify Update) ---
|
||||
print(f"Attempting to read updated note ID: {note_id}")
|
||||
read_updated_note = nc_client.notes_get_note(note_id=note_id)
|
||||
print(f"Updated note read: {read_updated_note}")
|
||||
assert read_updated_note["id"] == note_id
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
# Don't assert etag equality here either
|
||||
|
||||
# --- Test Update Conflict (Precondition Failed) ---
|
||||
print(f"Attempting to update note ID: {note_id} with OLD etag: {etag}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
nc_client.notes_update_note(
|
||||
note_id=note_id,
|
||||
etag=etag, # Use the OLD etag
|
||||
title="This update should fail",
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
print("Update with old etag correctly failed with 412.")
|
||||
|
||||
finally:
|
||||
# --- Delete ---
|
||||
if created_note and "id" in created_note:
|
||||
note_id_to_delete = created_note["id"]
|
||||
print(f"Attempting to delete note ID: {note_id_to_delete}")
|
||||
try:
|
||||
delete_response = nc_client.notes_delete_note(note_id=note_id_to_delete)
|
||||
print(f"Delete response: {delete_response}")
|
||||
# Check if delete returns the deleted object or just status
|
||||
# Assuming it returns the object based on previous tests
|
||||
assert delete_response["id"] == note_id_to_delete
|
||||
print(f"Note ID: {note_id_to_delete} deleted successfully.")
|
||||
|
||||
# --- Verify Delete ---
|
||||
print(f"Attempting to read deleted note ID: {note_id_to_delete}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo_del:
|
||||
nc_client.notes_get_note(note_id=note_id_to_delete)
|
||||
assert excinfo_del.value.response.status_code == 404
|
||||
print(f"Reading deleted note ID: {note_id_to_delete} correctly failed with 404.")
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# If deletion fails unexpectedly, log it but don't fail the test here
|
||||
# as the primary goal was CRUD, and cleanup failure is secondary.
|
||||
print(f"Error during cleanup (deleting note {note_id_to_delete}): {e}")
|
||||
except Exception as e:
|
||||
print(f"Unexpected error during cleanup: {e}")
|
||||
else:
|
||||
print("Skipping delete step as note creation might have failed or ID was not available.")
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""Test deleting a note that doesn't exist."""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
print(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
nc_client.notes_delete_note(note_id=non_existent_id)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
print(f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404.")
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user