Compare commits

...

549 Commits

Author SHA1 Message Date
Chris Coutinho 48744e8a6c ci: Publish to PyPI 2025-10-20 23:14:12 +02:00
Chris Coutinho 63b898c0e3 chore: Update logs 2025-10-20 22:57:18 +02:00
Chris Coutinho e8f1340133 fix(caldav): Fix caldav search() due to missing todos 2025-10-20 22:18:46 +02:00
Chris Coutinho fde68dac55 ci: Enable publish to test pypi 2025-10-20 20:27:01 +02:00
Chris Coutinho 460e2e190c ci: set workflow to be on workflow_dispatch 2025-10-20 20:22:07 +02:00
Chris Coutinho 989b6de3c0 build: Switch to uv build backend 2025-10-20 20:10:57 +02:00
Chris Coutinho aa0b6dc5dd docs: Update docs 2025-10-20 19:10:23 +02:00
Chris Coutinho 7ae78d3a39 Merge pull request #225 from cbcoutinho/feature/oidc-bump
Remove patch for OIDC app
2025-10-20 16:02:37 +02:00
Chris Coutinho 54326f9c64 Remove patch for OIDC app 2025-10-20 15:50:11 +02:00
Chris Coutinho 6ba87e7e05 chore: update caldav ref 2025-10-20 11:52:29 +02:00
github-actions[bot] 45bbf97033 bump: version 0.16.0 → 0.17.0 2025-10-19 22:55:23 +00:00
Chris Coutinho 14a0f166fe Merge pull request #223 from cbcoutinho/feature/caldav
Migrate to caldav and add support for VTODOs
2025-10-20 00:54:51 +02:00
Chris Coutinho 71f09a47ca docs: Update CalendarClient docstrings [skip ci] 2025-10-20 00:54:35 +02:00
Chris Coutinho 61bb8cc048 Merge pull request #224 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.1
2025-10-20 00:15:05 +02:00
renovate-bot-cbcoutinho[bot] ad9b9f25a1 chore(deps): update astral-sh/setup-uv action to v7.1.1 2025-10-19 22:05:34 +00:00
Chris Coutinho f4dd68735c test: Fix how categories are handled in calendar 2025-10-20 00:04:38 +02:00
Chris Coutinho c75f0c0a17 test: Revert creation 2025-10-19 23:59:07 +02:00
Chris Coutinho a143123acc fix(caldav): Check that calendar exists after creation to avoid race condition
Verify that field preservation tests still operate
2025-10-19 23:44:39 +02:00
Chris Coutinho 1dc2ddfdb7 fix(caldav): Properly parse datetimes as vDDDTypes 2025-10-19 20:13:05 +02:00
Chris Coutinho 92e18825bc feat(caldav): Add support for tasks 2025-10-19 18:02:43 +02:00
Chris Coutinho d398a8c8e6 refactor: Migrate from internal CalendarClient to caldav library 2025-10-19 15:47:17 +02:00
Chris Coutinho 39dfa13895 docs: Remove user API docs 2025-10-19 14:06:14 +02:00
github-actions[bot] cb7a609ec2 bump: version 0.15.2 → 0.16.0 2025-10-19 00:13:49 +00:00
Chris Coutinho b8d241b596 Merge pull request #219 from cbcoutinho/feature/load-testing
Feature/load testing
2025-10-19 02:13:18 +02:00
Chris Coutinho 5395f8d3d6 chore: Update lock file 2025-10-19 02:02:05 +02:00
Chris Coutinho 198d7495f0 ci: Remove --setup-show from pytest args 2025-10-19 01:58:22 +02:00
Chris Coutinho c2f6c6ce0d ci: Set cookbook recipe import timeout to 5min 2025-10-19 01:49:21 +02:00
Chris Coutinho 5757f2582b ci: Run oauth tests 2025-10-19 00:49:55 +02:00
Chris Coutinho d5e6411c45 test: disable asyncio fixture 2025-10-19 00:49:24 +02:00
Chris Coutinho f0c03ceede Merge pull request #221 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.9.4-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 1a51c77
2025-10-19 00:28:59 +02:00
Chris Coutinho 7818eb104e ci: Add --setup-show to pytest 2025-10-19 00:28:28 +02:00
Chris Coutinho b72514bb32 ci: Add pytest-timeout to dev deps 2025-10-19 00:27:19 +02:00
renovate-bot-cbcoutinho[bot] f51d3a2101 chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 1a51c77 2025-10-18 22:07:46 +00:00
Chris Coutinho 5de4055f9f ci: Set log level INFO 2025-10-19 00:05:00 +02:00
Chris Coutinho 95da43ea0f ci: Increase playwright timeout to 60s 2025-10-18 23:26:50 +02:00
Chris Coutinho ae47c5f3e6 ci: Use chromium 2025-10-18 23:12:53 +02:00
Chris Coutinho 31ffeba69b chore: Move timeout to recipe import 2025-10-18 23:12:31 +02:00
Chris Coutinho 963a504ae2 ci: Replace 0.5 stagger with 10s in CI 2025-10-18 22:57:47 +02:00
Chris Coutinho ead298c132 chore: revert conftest.py 2025-10-18 22:44:51 +02:00
Chris Coutinho 2f805e54b7 test: Migrate load test benchmark scripts to anyio
Remove unused redis container
2025-10-18 22:40:50 +02:00
Chris Coutinho 6158a890af feat(webdav): Add search and list favorite response tools 2025-10-18 22:02:26 +02:00
Chris Coutinho 240ceb3808 test: Migrate load test framework to anyio as well 2025-10-18 22:02:25 +02:00
Chris Coutinho 1459fe9bc8 test: Replace pytest-asyncio plugin fixtures with anyio fixtures 2025-10-18 22:02:25 +02:00
Chris Coutinho 37164dbdbc chore: sort imports 2025-10-18 22:02:25 +02:00
Chris Coutinho c3ff92a8c1 test: Cleanup testing fixtures regarding canceled scopes 2025-10-18 22:02:25 +02:00
Chris Coutinho 371d0c93a5 test: Update oauth benchmark tests 2025-10-18 22:02:25 +02:00
Chris Coutinho 644c59bf78 docs: remove old docs 2025-10-18 22:02:25 +02:00
Chris Coutinho 056b6fc9d6 test: Initialize load testing framework 2025-10-18 22:02:24 +02:00
Chris Coutinho 83917b3786 perf(notes): Improve notes search performance using async iterators 2025-10-18 22:02:19 +02:00
Chris Coutinho 955ad78f13 test: Add load testing framework 2025-10-18 22:02:19 +02:00
Chris Coutinho 3f04449a86 Merge pull request #220 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.9.4-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 4992e5c
2025-10-18 18:31:01 +02:00
renovate-bot-cbcoutinho[bot] 144a54c1ad chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 4992e5c 2025-10-18 16:08:33 +00:00
Chris Coutinho 90b4b2a038 Merge pull request #218 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.4
2025-10-18 12:41:19 +02:00
renovate-bot-cbcoutinho[bot] cdfab26c75 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.4 2025-10-18 04:07:22 +00:00
github-actions[bot] a389f2940e bump: version 0.15.1 → 0.15.2 2025-10-17 23:17:32 +00:00
Chris Coutinho 5e829fc7e7 refactor: Unify logging & remove factory deployment 2025-10-18 01:15:06 +02:00
Chris Coutinho 9c909b6e42 Merge pull request #217 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin docker.io/library/nginx docker tag to 61e0128
2025-10-17 09:21:50 +02:00
renovate-bot-cbcoutinho[bot] 9b29eabfaa chore(deps): pin docker.io/library/nginx docker tag to 61e0128 2025-10-17 04:07:05 +00:00
github-actions[bot] 7549c988f4 bump: version 0.15.0 → 0.15.1 2025-10-17 02:49:37 +00:00
Chris Coutinho 0145be4bbd Merge pull request #216 from cbcoutinho/feature/trigger
Fix timeouts (in CI)
2025-10-17 04:49:17 +02:00
Chris Coutinho b1207770ca docs: revert README 2025-10-17 04:47:46 +02:00
Chris Coutinho d694243723 test: Remove filter 2025-10-17 04:46:43 +02:00
Chris Coutinho 8e7191e0ea fix: Increase HTTP client timeout to 30s
The default 5s timeout was too short for Nextcloud Cookbook app to fetch and process recipes from external URLs, causing intermittent test failures with ReadTimeout errors.

Fixes intermittent CI failures in cookbook import tests.
2025-10-17 04:41:28 +02:00
Chris Coutinho dbcf9d93ca chore: Improve RequestError message details
Show exception type and cause when str(e) is empty for better debugging
2025-10-17 04:37:31 +02:00
Chris Coutinho 27519d0f62 test: Replace http server for recipes with nginx container 2025-10-17 04:30:03 +02:00
Chris Coutinho 2999d4b65e fix: Handle RequestError in mcp tools 2025-10-17 04:17:41 +02:00
Chris Coutinho 0fd32ecd34 test: Fix test networking 2025-10-17 03:58:36 +02:00
Chris Coutinho 604a2065cb chore: trigger 2025-10-17 03:40:40 +02:00
github-actions[bot] 0aeef1b87e bump: version 0.14.3 → 0.15.0 2025-10-17 01:25:56 +00:00
Chris Coutinho b65f10ed8e Merge pull request #215 from cbcoutinho/feature/cookbook-app
feat(cookbook): Add full Cookbook app support with 13 tools and 2 res…
2025-10-17 03:25:31 +02:00
Chris Coutinho 038fcddd48 docs: remove duplicate 2025-10-17 03:24:23 +02:00
Chris Coutinho 394b27ee4a docs: Update README with experimental warnings of OIDC support 2025-10-17 03:21:54 +02:00
Chris Coutinho 9de59db718 feat(cookbook): Add full Cookbook app support with 13 tools and 2 resources
- Import recipes from URLs using schema.org metadata
- Full CRUD operations for recipes
- Search, categorize, and organize recipes
- Manage keywords/tags and categories
- Configure app settings and trigger reindexing
2025-10-17 03:08:16 +02:00
github-actions[bot] 6734de8389 bump: version 0.14.2 → 0.14.3 2025-10-17 00:04:25 +00:00
Chris Coutinho 3cb31d07f1 Merge pull request #214 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.18,<1.19
2025-10-17 02:04:00 +02:00
renovate-bot-cbcoutinho[bot] 16b9123af3 fix(deps): update dependency mcp to >=1.18,<1.19 2025-10-16 19:20:47 +00:00
Chris Coutinho 51d1f075f5 test: Remove duplicated/interactive testing fixtures
All integration tests now run without interactive browser usage, simplifying CI and testing infrastructure
2025-10-16 19:46:29 +02:00
github-actions[bot] e0a68d47a5 bump: version 0.14.1 → 0.14.2 2025-10-16 08:32:29 +00:00
Chris Coutinho 832cb51dd3 Merge pull request #213 from cbcoutinho/renovate/pillow-12.x
fix(deps): update dependency pillow to v12
2025-10-16 10:32:04 +02:00
Chris Coutinho f6256c10db Merge pull request #212 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.3
2025-10-16 00:24:01 +02:00
renovate-bot-cbcoutinho[bot] 7b2002c1b5 fix(deps): update dependency pillow to v12 2025-10-15 22:09:01 +00:00
renovate-bot-cbcoutinho[bot] d150cf2e72 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.3 2025-10-15 22:08:49 +00:00
Chris Coutinho 3921d9b982 test: Refactor test fixtures into a oauth token factory 2025-10-15 21:15:18 +02:00
github-actions[bot] 9e4c20a4b1 bump: version 0.14.0 → 0.14.1 2025-10-15 15:26:35 +00:00
Chris Coutinho f26bca13f1 Merge pull request #211 from cbcoutinho/feature/docs-oauth
fix(oauth): Remove the option to force_register new clients
2025-10-15 17:26:09 +02:00
Chris Coutinho 46c6f2f294 test: Fix oauth tests by reusing callback server 2025-10-15 17:06:46 +02:00
Chris Coutinho 3ad9198f36 fix(oauth): Remove the option to force_register new clients 2025-10-15 16:27:22 +02:00
Chris Coutinho dafac734e6 docs: Update README 2025-10-15 14:51:36 +02:00
Chris Coutinho 97bbc18121 docs: Update README
Add comparison to the Nextcloud Assistant & Context Agent
2025-10-15 14:47:43 +02:00
github-actions[bot] 46deb0f726 bump: version 0.13.0 → 0.14.0 2025-10-15 09:53:45 +00:00
Chris Coutinho daacf08a54 Merge pull request #208 from cbcoutinho/feature/user-api
Feature/user api
2025-10-15 11:53:20 +02:00
Chris Coutinho cc2a5c9d58 test: Inc delay for alice 2025-10-15 11:36:54 +02:00
Chris Coutinho 26f8deff17 test: Increase stagger delay 0.5 -> 2s 2025-10-15 11:07:06 +02:00
Chris Coutinho fb3063e94e test: Increase callback timeout 10s -> 30s 2025-10-15 10:57:21 +02:00
Chris Coutinho 83f89e9394 chore: Update CLAUDE.md 2025-10-15 10:36:27 +02:00
Chris Coutinho 5db02313a1 test: Update share client to fix test, update passwords 2025-10-15 10:35:22 +02:00
Chris Coutinho b50e212f05 test: Add tests for sharing/groups 2025-10-15 03:46:01 +02:00
Chris Coutinho 85f8522085 feat: Add Groups API client 2025-10-15 03:43:25 +02:00
Chris Coutinho a38c795124 feat: add sharing API client and server tools 2025-10-15 02:59:26 +02:00
Chris Coutinho 7004104873 test: Fix multi-user tests 2025-10-15 02:11:17 +02:00
Chris Coutinho 7a4a31b52d fix: Update user/groups API to OCS v2 2025-10-15 00:05:22 +02:00
Chris Coutinho 898c2e72ae Merge remote-tracking branch 'origin/master' into feature/user-api 2025-10-14 23:43:03 +02:00
Chris Coutinho 8652684466 ci: [skip ci] Move oauth mcp tests to server subdir 2025-10-14 12:03:03 +02:00
Chris Coutinho 72ace9da9e ci: [skip ci] Move tests to subdirs 2025-10-14 02:08:45 +02:00
Chris Coutinho ab40127811 ci: [skip ci] Remove 2025-10-14 01:32:30 +02:00
github-actions[bot] 52044ef053 bump: version 0.12.6 → 0.13.0 2025-10-13 23:30:55 +00:00
Chris Coutinho 7103a795a1 Merge pull request #204 from cbcoutinho/feature/oauth2
Enable OAuth2 using Nextcloud user_oidc/oidc apps
2025-10-14 01:30:34 +02:00
Chris Coutinho 3ed24bd5e3 docs: restructure documentation 2025-10-14 01:23:49 +02:00
Chris Coutinho 1023a7d9c7 chore: Remove comments 2025-10-14 01:23:49 +02:00
Chris Coutinho afc82ce3dc chore: Validate auth server support for PKCE on startup 2025-10-14 01:23:45 +02:00
Chris Coutinho 057e25b653 chore: Add support for overriding public issuer URL
test: Add patch for PKCE support
2025-10-14 01:23:41 +02:00
Chris Coutinho 3c4535da75 test: Replace unittest class with simple tests 2025-10-14 01:23:40 +02:00
Chris Coutinho a4ca3e00a0 Revert "test: Skip for GITHUB_ACTIONS inside fixture"
This reverts commit 4d65e6952cc164fe0212faa807d1f659df3d2792.
2025-10-14 01:23:40 +02:00
Chris Coutinho d879904540 test: Skip for GITHUB_ACTIONS inside fixture 2025-10-14 01:23:40 +02:00
Chris Coutinho 2ae3c423e9 test: Skip interactive tests if GITHUB_ACTIONS is defined 2025-10-14 01:23:40 +02:00
Chris Coutinho e886eff4ed test: Fix typo in skipif condition 2025-10-14 01:23:39 +02:00
Chris Coutinho 23688f3f85 chore: Remove comments 2025-10-14 01:23:39 +02:00
Chris Coutinho 13e4915e38 test: Remove unused pytest fixtures 2025-10-14 01:23:39 +02:00
Chris Coutinho f48d3714d2 test: Add restart to mcp containers in docker-compose.yml 2025-10-14 01:23:39 +02:00
Chris Coutinho 558f5ab6a4 test: oauth 2025-10-14 01:23:39 +02:00
Chris Coutinho 23cffc606b test: Add --build flag to docker compose up 2025-10-14 01:23:39 +02:00
Chris Coutinho 949d383606 test: Install deps before wait, use firefox 2025-10-14 01:23:39 +02:00
Chris Coutinho 6ce411094c test: Enable tests via playwright, disable interactive in CI 2025-10-14 01:23:38 +02:00
Chris Coutinho 37b0577bfd test: Add asyncio tests using Playwright 2025-10-14 01:23:38 +02:00
Chris Coutinho 4b19964817 docs: Update docs 2025-10-14 01:23:38 +02:00
Chris Coutinho ea468889ce docs: Remove pip 2025-10-14 01:23:38 +02:00
Chris Coutinho bcf8daaa5d docs: Update README 2025-10-14 01:23:38 +02:00
Chris Coutinho 9ef9fff2b0 docs: Update Docs 2025-10-14 01:23:38 +02:00
Chris Coutinho 2489a714b8 docs: Update README and docs 2025-10-14 01:23:37 +02:00
Chris Coutinho a4a7fb48d6 chore: Update --help 2025-10-14 01:23:37 +02:00
Chris Coutinho f58a9883a6 test: Fix oauth2 token extract from starlette requests 2025-10-14 01:23:37 +02:00
Chris Coutinho b3b7c90bd0 chore: Move httpd server to separate fixture 2025-10-14 01:23:32 +02:00
Chris Coutinho b26ff4f9bc test: Fix oauth interactive browser tests 2025-10-14 01:23:32 +02:00
Chris Coutinho e42cabb6ed chore: logging 2025-10-14 01:23:32 +02:00
Chris Coutinho 4fae78a090 test: disable oauth in ci 2025-10-14 01:23:31 +02:00
Chris Coutinho b7b83880c0 chore: comments 2025-10-14 01:23:31 +02:00
Chris Coutinho 879cd58db1 test: rename interactive mark to oauth 2025-10-14 01:23:31 +02:00
Chris Coutinho 0c5d9a46bd test: fix typo 2025-10-14 01:23:31 +02:00
Chris Coutinho 605c8afacd test: Disable interactive tests for ci 2025-10-14 01:23:31 +02:00
Chris Coutinho 17979accb6 test: Add patch for user_oidc app and update docs 2025-10-14 01:23:31 +02:00
Chris Coutinho 7d8ba39434 test: update app install scripts 2025-10-14 01:23:30 +02:00
Chris Coutinho 2b11718c43 test: continue working on oauth client 2025-10-14 01:23:30 +02:00
Chris Coutinho 33b962a7fc test: Setup interactive browser test 2025-10-14 01:23:30 +02:00
Chris Coutinho 4d7e4b9a4b feat(server): Experimental support for OAuth2/OIDC authentication 2025-10-14 01:22:15 +02:00
Chris Coutinho fafede2282 Merge pull request #206 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.1
2025-10-14 00:14:38 +02:00
renovate-bot-cbcoutinho[bot] bad04573b5 chore(deps): update hoverkraft-tech/compose-action action to v2.4.1 2025-10-13 22:08:43 +00:00
Chris Coutinho ec503e3f73 Merge pull request #205 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.0
2025-10-13 07:13:11 +02:00
renovate-bot-cbcoutinho[bot] 55f326aa9a chore(deps): update astral-sh/setup-uv action to v7.1.0 2025-10-12 22:06:03 +00:00
github-actions[bot] b19eb37ee2 bump: version 0.12.5 → 0.12.6 2025-10-11 16:31:34 +00:00
Chris Coutinho 0fdbd56cf0 Merge pull request #200 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.17,<1.18
2025-10-11 18:31:03 +02:00
Chris Coutinho 31b218f174 Merge pull request #203 from cbcoutinho/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2.4.1
2025-10-11 18:30:16 +02:00
renovate-bot-cbcoutinho[bot] 34daaa380e chore(deps): update softprops/action-gh-release action to v2.4.1 2025-10-11 16:05:14 +00:00
Chris Coutinho 8d3a7775c9 Merge pull request #201 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 59b6e69
2025-10-11 09:39:26 +02:00
Chris Coutinho af7deff836 Merge pull request #202 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.2
2025-10-11 09:39:12 +02:00
renovate-bot-cbcoutinho[bot] 7695fbca0c chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.2 2025-10-10 22:09:50 +00:00
renovate-bot-cbcoutinho[bot] f16af39b97 chore(deps): update docker.io/library/redis:alpine docker digest to 59b6e69 2025-10-10 22:09:45 +00:00
renovate-bot-cbcoutinho[bot] 3340a63f86 fix(deps): update dependency mcp to >=1.17,<1.18 2025-10-10 16:08:58 +00:00
Chris Coutinho 5cda32098f Merge pull request #198 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.1
2025-10-10 18:00:33 +02:00
Chris Coutinho df09fff11c Merge pull request #199 from cbcoutinho/renovate/docker.io-library-mariadb-lts
chore(deps): update docker.io/library/mariadb:lts docker digest to ae61197
2025-10-10 18:00:09 +02:00
renovate-bot-cbcoutinho[bot] 391f418934 chore(deps): update docker.io/library/mariadb:lts docker digest to ae61197 2025-10-10 04:06:36 +00:00
renovate-bot-cbcoutinho[bot] e1f17c3386 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.1 2025-10-09 22:14:27 +00:00
Chris Coutinho 2e6f31ed41 Merge pull request #197 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to b4ab73c
2025-10-09 14:38:28 +02:00
renovate-bot-cbcoutinho[bot] 900d1bb462 chore(deps): update docker.io/library/redis:alpine docker digest to b4ab73c 2025-10-09 10:13:48 +00:00
Chris Coutinho d7f2f2b302 Merge pull request #196 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 0ea5184
2025-10-09 10:26:07 +02:00
renovate-bot-cbcoutinho[bot] 1402da0ac0 chore(deps): update docker.io/library/redis:alpine docker digest to 0ea5184 2025-10-09 04:06:45 +00:00
Chris Coutinho 6b50495c1d Merge pull request #195 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.0
2025-10-08 11:14:16 +02:00
renovate-bot-cbcoutinho[bot] 0f7f5171a4 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.0 2025-10-08 04:06:44 +00:00
Chris Coutinho f943fba432 Merge pull request #194 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7
2025-10-08 00:26:38 +02:00
renovate-bot-cbcoutinho[bot] 0d98d9dfa0 chore(deps): update astral-sh/setup-uv action to v7 2025-10-07 22:09:38 +00:00
Chris Coutinho 5b3baa5959 Merge pull request #192 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.24
2025-10-07 09:28:37 +02:00
Chris Coutinho a8784993b2 Merge pull request #193 from cbcoutinho/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2.4.0
2025-10-07 09:28:11 +02:00
renovate-bot-cbcoutinho[bot] 431644fff6 chore(deps): update softprops/action-gh-release action to v2.4.0 2025-10-07 04:06:56 +00:00
renovate-bot-cbcoutinho[bot] fb2632e044 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.24 2025-10-07 04:06:48 +00:00
Chris Coutinho 3be62a095c Merge pull request #191 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.23
2025-10-05 08:43:23 +02:00
renovate-bot-cbcoutinho[bot] aead059eaa chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.23 2025-10-04 22:05:42 +00:00
Chris Coutinho 90eb43b926 Merge pull request #185 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 3e70e4d
2025-10-04 18:09:45 +02:00
renovate-bot-cbcoutinho[bot] 5f3ff60531 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 3e70e4d 2025-10-04 16:07:30 +00:00
Chris Coutinho 60743a9f1c Merge pull request #187 from cbcoutinho/renovate/redis-replacement
chore(deps): replace redis docker tag with docker.io/library/redis alpine
2025-10-04 14:50:51 +02:00
Chris Coutinho 669f678d63 Merge pull request #189 from cbcoutinho/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2.3.4
2025-10-04 14:50:31 +02:00
renovate-bot-cbcoutinho[bot] 1cf783d062 chore(deps): update softprops/action-gh-release action to v2.3.4 2025-10-03 22:07:27 +00:00
renovate-bot-cbcoutinho[bot] 7463234ccb chore(deps): replace redis docker tag with docker.io/library/redis alpine 2025-10-03 22:07:16 +00:00
github-actions[bot] b60da57597 bump: version 0.12.4 → 0.12.5 2025-10-03 06:20:51 +00:00
Chris Coutinho 0c9645bb3c Merge pull request #184 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.16,<1.17
2025-10-03 08:20:26 +02:00
renovate-bot-cbcoutinho[bot] b10fba0678 fix(deps): update dependency mcp to >=1.16,<1.17 2025-10-02 22:10:23 +00:00
Chris Coutinho b23ccb57d5 Merge pull request #181 from cbcoutinho/renovate/mariadb-replacement
chore(deps): replace mariadb docker tag with docker.io/library/mariadb lts
2025-10-02 13:33:45 +02:00
renovate-bot-cbcoutinho[bot] 0faa32fd10 chore(deps): replace mariadb docker tag with docker.io/library/mariadb lts 2025-10-02 10:04:52 +00:00
Chris Coutinho 8a9fa2a3c4 Merge pull request #180 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f4d0a4a
2025-10-02 08:08:04 +02:00
Chris Coutinho 8075d5fd9f Merge pull request #182 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 24264e9
2025-10-02 08:07:23 +02:00
renovate-bot-cbcoutinho[bot] 9be03ef0de chore(deps): update mariadb:lts docker digest to 24264e9 2025-10-02 04:04:12 +00:00
renovate-bot-cbcoutinho[bot] eda6753253 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f4d0a4a 2025-10-01 22:06:55 +00:00
Chris Coutinho 360a15959c Merge pull request #178 from cbcoutinho/renovate/nextcloud-replacement
chore(deps): replace nextcloud docker tag with docker.io/library/nextcloud 32.0.0
2025-10-01 18:08:09 +02:00
renovate-bot-cbcoutinho[bot] e6dc14c31f chore(deps): replace nextcloud docker tag with docker.io/library/nextcloud 32.0.0 2025-10-01 16:04:54 +00:00
Chris Coutinho bcc909bb83 Merge pull request #174 from cbcoutinho/renovate/nextcloud-32.x
chore(deps): update nextcloud docker tag to v32
2025-10-01 11:31:46 +02:00
Chris Coutinho e5fe7c6d84 Merge pull request #177 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.0
2025-10-01 09:32:21 +02:00
renovate-bot-cbcoutinho[bot] 1a2a1f065f chore(deps): update nextcloud docker tag to v32 2025-09-30 22:11:16 +00:00
renovate-bot-cbcoutinho[bot] 7c677205bb chore(deps): update hoverkraft-tech/compose-action action to v2.4.0 2025-09-30 22:11:11 +00:00
Chris Coutinho 91cc76be8c Merge pull request #176 from cbcoutinho/renovate/astral-sh-setup-uv-6.x
chore(deps): update astral-sh/setup-uv action to v6.8.0
2025-10-01 00:00:50 +02:00
renovate-bot-cbcoutinho[bot] 593c84345e chore(deps): update astral-sh/setup-uv action to v6.8.0 2025-09-30 16:11:04 +00:00
Chris Coutinho 71fd823d84 Merge pull request #173 from cbcoutinho/feature/stargazer
chore: Update README.md
2025-09-30 09:57:30 +02:00
Chris Coutinho 3723bf9a52 Merge pull request #172 from cbcoutinho/renovate/docker-login-action-digest
chore(deps): update docker/login-action digest to 5e57cd1
2025-09-29 18:22:17 +02:00
Chris Coutinho 7e3c2c9774 chore: Update README.md 2025-09-29 18:20:56 +02:00
renovate-bot-cbcoutinho[bot] 0e0bfd9f98 chore(deps): update docker/login-action digest to 5e57cd1 2025-09-29 16:06:20 +00:00
Chris Coutinho 752c22147c Merge pull request #170 from cbcoutinho/renovate/nextcloud-31.0.9
chore(deps): update nextcloud:31.0.9 docker digest to 88fe398
2025-09-29 09:13:24 +02:00
Chris Coutinho 4c07ca9f0a Merge pull request #171 from cbcoutinho/renovate/lock-file-maintenance
chore(deps): lock file maintenance
2025-09-29 06:51:10 +02:00
renovate-bot-cbcoutinho[bot] 55945c6c0f chore(deps): lock file maintenance 2025-09-29 04:12:15 +00:00
renovate-bot-cbcoutinho[bot] 3f8312e6f3 chore(deps): update nextcloud:31.0.9 docker digest to 88fe398 2025-09-28 22:05:34 +00:00
Chris Coutinho c39b69d08c Merge pull request #169 from cbcoutinho/renovate/nextcloud-31.0.9
chore(deps): update nextcloud:31.0.9 docker digest to 875511f
2025-09-27 13:27:12 +02:00
renovate-bot-cbcoutinho[bot] 290ad2edc2 chore(deps): update nextcloud:31.0.9 docker digest to 875511f 2025-09-27 10:05:24 +00:00
github-actions[bot] 144c08c339 bump: version 0.12.3 → 0.12.4 2025-09-25 16:17:59 +00:00
Chris Coutinho b461af8aa1 Merge pull request #156 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.15,<1.16
2025-09-25 18:17:31 +02:00
renovate-bot-cbcoutinho[bot] 4bdf67b042 fix(deps): update dependency mcp to >=1.15,<1.16 2025-09-25 16:07:30 +00:00
github-actions[bot] 93b109e5b9 bump: version 0.12.2 → 0.12.3 2025-09-23 22:22:36 +00:00
Chris Coutinho 0c5ebd5d84 Merge pull request #168 from cbcoutinho/feature/tools
Add tools for all resources to enable tool-only workflows
2025-09-24 00:22:11 +02:00
Chris Coutinho 79e6250377 update deprecated log warnings 2025-09-24 00:17:57 +02:00
Chris Coutinho a5ec712b88 Merge pull request #167 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.22
2025-09-24 00:15:08 +02:00
Chris Coutinho cc9650b077 refactor: Add tools for all resources to enable tool-only workflows 2025-09-24 00:13:24 +02:00
renovate-bot-cbcoutinho[bot] 1a37a6c1fe chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.22 2025-09-23 22:07:49 +00:00
Chris Coutinho 4572287870 Merge pull request #165 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.20
2025-09-23 12:35:20 +02:00
renovate-bot-cbcoutinho[bot] 67617d7fcc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.20 2025-09-23 04:07:43 +00:00
github-actions[bot] 22811f29f6 bump: version 0.12.1 → 0.12.2 2025-09-20 20:34:35 +00:00
Chris Coutinho 71da620099 refactor: Add http to --transport option 2025-09-20 22:23:13 +02:00
Chris Coutinho de7c848aa6 Merge pull request #164 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.19
2025-09-20 11:44:35 +02:00
renovate-bot-cbcoutinho[bot] 8d4303a624 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.19 2025-09-19 22:07:37 +00:00
Chris Coutinho 4c7880a4e5 Merge pull request #163 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.18
2025-09-18 11:49:09 +02:00
renovate-bot-cbcoutinho[bot] 0a307b87ae chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.18 2025-09-17 22:06:28 +00:00
Chris Coutinho 48eced80fb Merge pull request #162 from cbcoutinho/renovate/nextcloud-31.0.9
chore(deps): update nextcloud:31.0.9 docker digest to 11f1580
2025-09-17 08:36:48 +02:00
renovate-bot-cbcoutinho[bot] aafac732c6 chore(deps): update nextcloud:31.0.9 docker digest to 11f1580 2025-09-17 04:04:06 +00:00
Chris Coutinho 12d48bb920 Merge pull request #161 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 851a602
2025-09-16 08:42:21 +02:00
renovate-bot-cbcoutinho[bot] 0600cea87b chore(deps): update mariadb:lts docker digest to 851a602 2025-09-16 04:05:11 +00:00
Chris Coutinho 145141e1d8 Merge pull request #160 from cbcoutinho/renovate/nextcloud-31.x
chore(deps): update nextcloud docker tag to v31.0.9
2025-09-16 00:17:58 +02:00
renovate-bot-cbcoutinho[bot] 948e7a4d91 chore(deps): update nextcloud docker tag to v31.0.9 2025-09-15 22:07:01 +00:00
Chris Coutinho 39ff811d1a Merge pull request #159 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to b75a909
2025-09-14 20:47:05 +02:00
Chris Coutinho cfd03a761b ci: pin 2025-09-14 20:42:14 +02:00
renovate-bot-cbcoutinho[bot] e7b37312a7 chore(deps): update astral-sh/setup-uv digest to b75a909 2025-09-14 16:03:58 +00:00
Chris Coutinho 4ad47b4fa3 Merge pull request #158 from cbcoutinho/renovate/lock-file-maintenance
chore(deps): lock file maintenance
2025-09-13 11:13:51 +02:00
renovate-bot-cbcoutinho[bot] ffbb86df57 chore(deps): lock file maintenance 2025-09-13 09:02:50 +00:00
Chris Coutinho 7a57247a9c Merge pull request #157 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 92bc503
2025-09-12 18:56:43 +02:00
renovate-bot-cbcoutinho[bot] 4ea6ce3477 chore(deps): update nextcloud:31.0.8 docker digest to 92bc503 2025-09-12 16:05:34 +00:00
github-actions[bot] fad2cd8dcb bump: version 0.12.0 → 0.12.1 2025-09-11 15:45:22 +00:00
Chris Coutinho 06042357f8 fix(docker): Provide --host 0.0.0.0 in default docker image 2025-09-11 17:44:45 +02:00
Chris Coutinho 5bdf840098 chore: Update docker-compose.yml 2025-09-11 17:36:00 +02:00
Chris Coutinho 9711d1d161 docs: fix duplicate 2025-09-11 17:31:00 +02:00
Chris Coutinho 2d802483e5 Merge branch 'master' of github.com:cbcoutinho/nextcloud-mcp-server 2025-09-11 17:28:35 +02:00
Chris Coutinho b3cd2ace34 chore: Update README.md, move docs to directory 2025-09-11 17:28:13 +02:00
Chris Coutinho 2cd91ceee7 chore: Update README and help text 2025-09-11 17:10:58 +02:00
github-actions[bot] 84106a059e bump: version 0.11.1 → 0.12.0 2025-09-11 15:02:22 +00:00
Chris Coutinho c1c5a61952 feat(server): Add support for streamable-http transport type 2025-09-11 17:01:29 +02:00
github-actions[bot] e7c4eb0842 bump: version 0.11.0 → 0.11.1 2025-09-11 14:21:48 +00:00
Chris Coutinho 2f60dec90d Merge pull request #80 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.13,<1.14
2025-09-11 16:21:24 +02:00
renovate-bot-cbcoutinho[bot] 59633017b0 fix(deps): update dependency mcp to >=1.13,<1.14 2025-09-11 14:15:39 +00:00
Chris Coutinho 961f23b5ea feat(users): Initialize user API client 2025-09-11 09:42:42 +02:00
github-actions[bot] 6fa59621bf bump: version 0.10.0 → 0.11.0 2025-09-11 07:40:38 +00:00
Chris Coutinho c2284298ce Merge pull request #155 from cbcoutinho/feature/deck
Initialize Deck app client/server
2025-09-11 09:40:11 +02:00
Chris Coutinho 7498b501eb chore: Remove remaining tools 2025-09-11 09:31:13 +02:00
Chris Coutinho 652c58d1fb chore: fix test 2025-09-11 00:40:16 +02:00
Chris Coutinho e7a5caa0d6 Merge remote-tracking branch 'origin/master' into feature/deck 2025-09-11 00:37:58 +02:00
Chris Coutinho d2d413afcd feat(deck): Add support for stack, cards, labels 2025-09-11 00:35:02 +02:00
github-actions[bot] 3c3df0d3a5 bump: version 0.9.0 → 0.10.0 2025-09-10 22:13:45 +00:00
Chris Coutinho c59bcca053 Merge pull request #154 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.17
2025-09-11 00:13:16 +02:00
Chris Coutinho 18973e061a Merge pull request #150 from pedrxd/task/mr-move-webdav
feat: Add WebDAV resource move/rename/copy functionality
2025-09-11 00:12:23 +02:00
Chris Coutinho 167053578d feat(deck): Initialize Deck app client/server 2025-09-11 00:10:25 +02:00
renovate-bot-cbcoutinho[bot] 2633b63a04 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.17 2025-09-10 22:05:55 +00:00
Pedro Ruiz 5d4902a73e feat: Add WebDAV resource copy functionality 2025-09-10 22:15:16 +02:00
Pedro Ruiz b55b9640c6 feat: Add WebDAV resource move/rename functionality 2025-09-10 22:12:17 +02:00
github-actions[bot] b1eb4d2497 bump: version 0.8.3 → 0.9.0 2025-09-10 15:24:13 +00:00
Chris Coutinho 6c580fec01 Merge pull request #148 from cbcoutinho/feature/uvicorn
Replace mcp run with uvicorn
2025-09-10 17:23:48 +02:00
Chris Coutinho bbd8d1cf63 feat(cli): Replace mcp run with click CLI and runtime options
BREAKING CHANGE: FASTMCP_-prefixed env vars have been replaced by CLI
arguments. Refer to the README for updated usage.

Usage: python -m nextcloud_mcp_server.app [OPTIONS]

Options:
  -h, --host TEXT
  -p, --port INTEGER
  -w, --workers INTEGER
  -r, --reload
  --log-level [critical|error|warning|info|debug|trace]
  -t, --transport [sse|streamable-http]
  -e, --enable-app [notes|tables|webdav|calendar|contacts]
                                  Enable specific Nextcloud app APIs. Can be
                                  specified multiple times. If not specified,
                                  all apps are enabled.
  --help                          Show this message and exit.
2025-09-10 17:19:12 +02:00
Chris Coutinho d01c6ee0d0 Merge pull request #152 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.16
2025-09-10 09:10:13 +02:00
renovate-bot-cbcoutinho[bot] d48b93e8fc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.16 2025-09-10 04:04:38 +00:00
Chris Coutinho 7b663c5476 Merge pull request #151 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to c3329db
2025-09-09 23:47:13 +02:00
renovate-bot-cbcoutinho[bot] 73257e749f chore(deps): update nextcloud:31.0.8 docker digest to c3329db 2025-09-09 21:44:22 +00:00
Chris Coutinho d66faa9533 Merge pull request #149 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to f26bb78
2025-09-09 13:24:14 +02:00
renovate-bot-cbcoutinho[bot] 58fd0283ea chore(deps): update nextcloud:31.0.8 docker digest to f26bb78 2025-09-09 10:04:26 +00:00
Chris Coutinho 3feac952da Merge pull request #147 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 6205056
2025-09-09 06:55:12 +02:00
renovate-bot-cbcoutinho[bot] 6a2ed9815a chore(deps): update nextcloud:31.0.8 docker digest to 6205056 2025-09-09 04:04:52 +00:00
Chris Coutinho c1c01196a4 Merge pull request #146 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin softprops/action-gh-release action to 6cbd405
2025-09-08 07:55:49 +02:00
renovate-bot-cbcoutinho[bot] 930bb280fe chore(deps): pin softprops/action-gh-release action to 6cbd405 2025-09-08 04:07:08 +00:00
Chris Coutinho e36d020f6b chore: Update README 2025-09-08 00:31:27 +02:00
Chris Coutinho c13240819a Merge pull request #145 from cbcoutinho/renovate/softprops-action-gh-release-digest
chore(deps): update softprops/action-gh-release digest to 6cbd405
2025-09-08 00:22:19 +02:00
Chris Coutinho c2c2a71c4b ci: bump dep 2025-09-08 00:11:13 +02:00
renovate-bot-cbcoutinho[bot] 21f6164e07 chore(deps): update softprops/action-gh-release digest to 6cbd405 2025-09-07 10:04:29 +00:00
Chris Coutinho 420fa9173d Merge pull request #144 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.8.15-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:0.8.15-python3.11-alpine docker digest to e471ce4
2025-09-03 18:19:54 +02:00
renovate-bot-cbcoutinho[bot] da4d48c493 chore(deps): update ghcr.io/astral-sh/uv:0.8.15-python3.11-alpine docker digest to e471ce4 2025-09-03 16:05:56 +00:00
Chris Coutinho 404abe8695 Merge pull request #143 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.15
2025-09-03 07:42:55 +02:00
renovate-bot-cbcoutinho[bot] 28dd24510d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.15 2025-09-03 04:04:52 +00:00
Chris Coutinho f72bb7e996 Merge pull request #142 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to ec5d50f
2025-09-02 10:33:34 +02:00
renovate-bot-cbcoutinho[bot] 9c0a0e9bf3 chore(deps): update mariadb:lts docker digest to ec5d50f 2025-09-02 04:05:18 +00:00
Chris Coutinho 78b96177bd Merge pull request #141 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 557e51d
2025-09-01 18:28:23 +02:00
renovate-bot-cbcoutinho[bot] 70b0754a19 chore(deps): update astral-sh/setup-uv digest to 557e51d 2025-09-01 16:06:39 +00:00
github-actions[bot] f034012101 bump: version 0.8.2 → 0.8.3 2025-08-31 19:22:11 +00:00
Chris Coutinho 7c4c0284f3 Merge pull request #140 from cbcoutinho/feature/etag
fix(notes): Include ETags in responses to avoid accidently updates
2025-08-31 21:21:50 +02:00
Chris Coutinho 892340fb66 chore: Remove unused model SuccessResponse 2025-08-31 21:15:43 +02:00
Chris Coutinho f79b957644 test: Update tests with McpError 2025-08-31 21:08:04 +02:00
Chris Coutinho ef1fb9e9aa fix(server): Replace ErrorResponses with standard McpErrors 2025-08-31 20:58:12 +02:00
Chris Coutinho d712b5487c test(notes): Modify tests with updated error handling 2025-08-31 19:32:39 +02:00
Chris Coutinho 892a8d2d23 fix(notes): Include ETags in responses to avoid accidently updates 2025-08-31 19:20:51 +02:00
github-actions[bot] daeb95f3c3 bump: version 0.8.1 → 0.8.2 2025-08-31 10:36:56 +00:00
Chris Coutinho 36d44d1781 Merge pull request #139 from cbcoutinho/feature/notes-no-return-content
fix(notes): Remove note contents from responses to reduce token usage
2025-08-31 12:36:30 +02:00
Chris Coutinho 949fb7124b fix(notes): Remove note contents from responses to reduce token usage 2025-08-31 11:55:15 +02:00
github-actions[bot] 6c4f071d2b bump: version 0.8.0 → 0.8.1 2025-08-30 20:38:13 +00:00
Chris Coutinho 53b11f7fbb fix(model): Serialize timestamps in RFC3339 format 2025-08-30 22:37:16 +02:00
Chris Coutinho 336bc45637 Merge pull request #138 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to fcf6370
2025-08-30 20:29:17 +02:00
renovate-bot-cbcoutinho[bot] 6c587bb265 chore(deps): update nextcloud:31.0.8 docker digest to fcf6370 2025-08-30 18:19:45 +00:00
github-actions[bot] 6b1f5c12c8 bump: version 0.7.2 → 0.8.0 2025-08-30 17:28:57 +00:00
Chris Coutinho f8dc1f060b Merge pull request #137 from cbcoutinho/feature/claude-code
Feature/claude code
2025-08-30 19:28:33 +02:00
Chris Coutinho 4cf5f2a95a feat(client): Preserve fields when modifying contacts/calendar resources 2025-08-30 19:19:20 +02:00
Chris Coutinho 1cc65f0160 chore: Remove unused model 2025-08-30 18:31:45 +02:00
Chris Coutinho 9b00530e8e feat(server): Add structured output to all tool/resource output
BREAKING CHANGE
2025-08-30 18:27:32 +02:00
Chris Coutinho 938376425b chore: Update CLAUDE.md 2025-08-30 14:34:25 +02:00
Chris Coutinho 0484167a22 refactor: Use _make_request where available 2025-08-30 14:27:53 +02:00
Chris Coutinho 84ad1958af chore: Remove unnecessary logging
Migrate pre-commit tasks to local
2025-08-30 14:25:16 +02:00
Chris Coutinho fa002296ff chore(claude): Initialize CLAUDE.md 2025-08-30 13:23:34 +02:00
github-actions[bot] 464ff2c8b2 bump: version 0.7.1 → 0.7.2 2025-08-30 10:15:06 +00:00
Chris Coutinho 0804ff8d17 Merge pull request #136 from rnivet/fix/get-all-notes-paging
fix(client): Use paging to fetch all notes
2025-08-30 12:14:45 +02:00
Rémi Nivet 4f7023a16e fix(client): Use paging to fetch all notes 2025-08-29 23:46:58 +02:00
Chris Coutinho 8f6656c546 Merge pull request #134 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 3eaddb0
2025-08-29 12:53:52 +02:00
Chris Coutinho 741c58d9a3 Merge pull request #135 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.14
2025-08-29 12:53:42 +02:00
renovate-bot-cbcoutinho[bot] e7b79d0316 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.14 2025-08-29 10:25:25 +00:00
renovate-bot-cbcoutinho[bot] 0e4cc8e56f chore(deps): update nextcloud:31.0.8 docker digest to 3eaddb0 2025-08-29 10:25:20 +00:00
Chris Coutinho 16da7a9a76 Merge pull request #133 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.13
2025-08-22 13:06:28 +02:00
renovate-bot-cbcoutinho[bot] 520e515f2b chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.13 2025-08-21 22:14:57 +00:00
Chris Coutinho fd6ce7b294 Merge pull request #132 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 4959332
2025-08-21 12:45:28 +02:00
renovate-bot-cbcoutinho[bot] 8063059f5f chore(deps): update astral-sh/setup-uv digest to 4959332 2025-08-21 10:04:51 +00:00
Chris Coutinho 20c5046b20 Merge pull request #130 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 987c376
2025-08-19 11:50:51 +02:00
Chris Coutinho 68126640d8 Merge pull request #131 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.12
2025-08-19 11:50:10 +02:00
renovate-bot-cbcoutinho[bot] af617e3869 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.12 2025-08-19 04:04:58 +00:00
renovate-bot-cbcoutinho[bot] 04e5f7beca chore(deps): update redis:alpine docker digest to 987c376 2025-08-19 04:04:54 +00:00
Chris Coutinho 6ed1efab24 Merge pull request #129 from cbcoutinho/renovate/nextcloud-31.0.8
chore(deps): update nextcloud:31.0.8 docker digest to 72abe18
2025-08-17 23:30:34 +02:00
renovate-bot-cbcoutinho[bot] cffa002364 chore(deps): update nextcloud:31.0.8 docker digest to 72abe18 2025-08-17 16:04:16 +00:00
Chris Coutinho 951a7095b2 Merge pull request #127 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.11
2025-08-16 20:04:50 +02:00
Chris Coutinho ee31f33038 Merge pull request #128 from cbcoutinho/renovate/nextcloud-31.x
chore(deps): update nextcloud docker tag to v31.0.8
2025-08-15 14:18:22 +02:00
renovate-bot-cbcoutinho[bot] 0fdbfae198 chore(deps): update nextcloud docker tag to v31.0.8 2025-08-15 04:08:58 +00:00
renovate-bot-cbcoutinho[bot] 315f918d88 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.11 2025-08-14 22:11:23 +00:00
Chris Coutinho 96a8491a4c Merge pull request #123 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to d9e0f98
2025-08-13 10:00:32 +02:00
Chris Coutinho 0a311766f2 Merge pull request #124 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 272084c
2025-08-13 09:59:56 +02:00
Chris Coutinho d28c249f8d Merge pull request #125 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to b255a97
2025-08-13 09:59:47 +02:00
renovate-bot-cbcoutinho[bot] ab6cac8799 chore(deps): update nextcloud:31.0.7 docker digest to b255a97 2025-08-13 04:05:37 +00:00
renovate-bot-cbcoutinho[bot] 7127b9953f chore(deps): update mariadb:lts docker digest to 272084c 2025-08-13 04:05:33 +00:00
renovate-bot-cbcoutinho[bot] 49c9af3c76 chore(deps): update astral-sh/setup-uv digest to d9e0f98 2025-08-12 22:08:22 +00:00
Chris Coutinho 823151f42e Merge pull request #122 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.9
2025-08-12 13:31:53 +02:00
renovate-bot-cbcoutinho[bot] 2bbd56e1cd chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.9 2025-08-12 04:05:16 +00:00
Chris Coutinho 8a36a120a7 Merge pull request #121 from cbcoutinho/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-08-11 22:39:16 +02:00
renovate-bot-cbcoutinho[bot] 9df8cc937d chore(deps): update actions/checkout action to v5 2025-08-11 16:07:14 +00:00
Chris Coutinho 325dcdf654 Merge pull request #118 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.8
2025-08-09 09:09:45 +02:00
renovate-bot-cbcoutinho[bot] 945eb1eb4e chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.8 2025-08-09 04:04:39 +00:00
Chris Coutinho 088343d003 Merge pull request #117 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.7
2025-08-09 01:14:56 +02:00
renovate-bot-cbcoutinho[bot] 94d553985f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.7 2025-08-08 22:07:52 +00:00
github-actions[bot] 982dbd18ca bump: version 0.7.0 → 0.7.1 2025-08-08 19:04:17 +00:00
Chris Coutinho 054fa38e3a Merge pull request #116 from cbcoutinho/fix/csrf-cookies
Strip cookies from responses to avoid falsely raising CS…
2025-08-08 21:03:56 +02:00
Chris Coutinho 3836534205 fix(client): Strip cookies from responses to avoid falsely raising CSRF errors 2025-08-08 21:03:16 +02:00
Chris Coutinho f852a18b12 Merge pull request #114 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.6
2025-08-08 13:11:56 +02:00
renovate-bot-cbcoutinho[bot] 0450c5cc52 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.6 2025-08-07 16:06:38 +00:00
Chris Coutinho f48fd0be60 Merge pull request #113 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to a834f43
2025-08-07 09:11:06 +02:00
renovate-bot-cbcoutinho[bot] ee29194bc9 chore(deps): update nextcloud:31.0.7 docker digest to a834f43 2025-08-07 04:06:07 +00:00
Chris Coutinho fc32fa2852 Merge pull request #112 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 7521abd
2025-08-06 20:53:55 +02:00
renovate-bot-cbcoutinho[bot] b7d6548741 chore(deps): update redis:alpine docker digest to 7521abd 2025-08-06 10:05:20 +00:00
Chris Coutinho a9ffd49815 Merge pull request #111 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.5
2025-08-06 02:52:55 +02:00
renovate-bot-cbcoutinho[bot] 538f861414 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.5 2025-08-05 22:09:00 +00:00
Chris Coutinho b784651f7f Merge pull request #110 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to 33c21e8
2025-08-05 18:27:41 +02:00
renovate-bot-cbcoutinho[bot] 6f0baf5fca chore(deps): update nextcloud:31.0.7 docker digest to 33c21e8 2025-08-05 16:04:55 +00:00
Chris Coutinho 664254ed95 Merge pull request #108 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to e716e2f
2025-08-05 14:55:04 +02:00
Chris Coutinho b976494ca2 Merge pull request #109 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to a0fc425
2025-08-05 14:54:55 +02:00
renovate-bot-cbcoutinho[bot] 061f667e00 chore(deps): update redis:alpine docker digest to a0fc425 2025-08-05 10:05:41 +00:00
renovate-bot-cbcoutinho[bot] 3319c35798 chore(deps): update nextcloud:31.0.7 docker digest to e716e2f 2025-08-05 10:05:35 +00:00
Chris Coutinho 52c9293c37 Merge pull request #106 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to fb96127
2025-08-05 08:54:31 +02:00
Chris Coutinho af6863a764 Merge pull request #107 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to 6b268fb
2025-08-05 08:53:01 +02:00
renovate-bot-cbcoutinho[bot] 77181f7c6f chore(deps): update nextcloud:31.0.7 docker digest to 6b268fb 2025-08-05 04:05:19 +00:00
renovate-bot-cbcoutinho[bot] 61f3beac01 chore(deps): update redis:alpine docker digest to fb96127 2025-08-04 22:07:46 +00:00
Chris Coutinho 49aaf24363 Merge pull request #105 from cbcoutinho/renovate/docker-login-action-digest
chore(deps): update docker/login-action digest to 184bdaa
2025-08-04 19:22:12 +02:00
renovate-bot-cbcoutinho[bot] 4edd31ee28 chore(deps): update docker/login-action digest to 184bdaa 2025-08-04 16:05:38 +00:00
github-actions[bot] 9ae2a0fc6f bump: version 0.6.1 → 0.7.0 2025-08-03 12:47:13 +00:00
Chris Coutinho 8386644dfd Merge pull request #104 from cbcoutinho/feature/vcard
Initialize Contacts App
2025-08-03 14:46:48 +02:00
Chris Coutinho 1dfdad5fad Update README, docstrings, and test scope for temporary_addressbook 2025-08-03 14:42:16 +02:00
Chris Coutinho 72cb62a101 test(contacts): Add unit/integration tests for a few tools 2025-08-03 14:36:16 +02:00
Chris Coutinho 21fc55320b Fix scoping 2025-08-03 14:25:01 +02:00
Chris Coutinho ad3e288203 test: Replace test_*_clients with single nc_client for tests 2025-08-03 14:22:45 +02:00
Chris Coutinho 0a97357a9c remove main.py 2025-08-03 14:17:29 +02:00
Chris Coutinho 70f01bf40a Add files 2025-08-03 14:16:55 +02:00
Chris Coutinho 37b1057d2a feat(contacts): Initialize Contacts App 2025-08-03 14:15:37 +02:00
Chris Coutinho ad95140416 Merge pull request #102 from cbcoutinho/renovate/docker-metadata-action-digest
chore(deps): update docker/metadata-action digest to c1e5197
2025-08-01 12:43:12 +02:00
github-actions[bot] 73fb56f73d bump: version 0.6.0 → 0.6.1 2025-08-01 10:41:12 +00:00
Chris Coutinho 9cc5300aa8 Merge pull request #96 from cbcoutinho/refactor/server
Refactor server tools and resources
2025-08-01 12:40:52 +02:00
Chris Coutinho be466abc0c Update README for deployment 2025-08-01 12:36:52 +02:00
Chris Coutinho 8956945e9d chore: sort imports 2025-08-01 12:21:32 +02:00
Chris Coutinho a9f3e1b00d Remove app check 2025-08-01 12:16:11 +02:00
Chris Coutinho a5e3f949c2 Use unique calendar_test_client 2025-08-01 12:08:27 +02:00
renovate-bot-cbcoutinho[bot] acc505aa01 chore(deps): update docker/metadata-action digest to c1e5197 2025-08-01 10:06:53 +00:00
Chris Coutinho 69fccb496a Use self._make_request 2025-08-01 11:05:28 +02:00
Chris Coutinho 6bdbb6ea6c Create sample calendar 2025-08-01 10:26:56 +02:00
Chris Coutinho 0b8a3aa646 Prepare calendar before running tests 2025-08-01 09:29:15 +02:00
Chris Coutinho ed270bb926 Add OCS-APIRequest: true to tables app check 2025-08-01 09:11:14 +02:00
Chris Coutinho 56e5298cce Wait for apps to be installed 2025-08-01 09:07:01 +02:00
Chris Coutinho 2bcfd3d7ee fix(calendar): Fix iCalendar date vs datetime format 2025-08-01 08:34:51 +02:00
Chris Coutinho 75235d6013 Refactor datetime 2025-07-31 14:51:33 +02:00
Chris Coutinho 19631838bb Merge remote-tracking branch 'origin/master' into refactor/server 2025-07-31 11:50:17 +02:00
Chris Coutinho 3cab343416 Merge pull request #99 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.4
2025-07-31 07:22:55 +02:00
renovate-bot-cbcoutinho[bot] 1a253af1c0 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.4 2025-07-30 22:06:16 +00:00
Chris Coutinho b81fe6dfa0 fix(calendar): Remove try/except in calendar API 2025-07-30 11:03:01 +02:00
Chris Coutinho 2a5b12343c chore: pre-commit 2025-07-29 15:13:02 +02:00
Chris Coutinho 66d306708d test(calendar): Enable calendar app in CICD 2025-07-29 15:12:39 +02:00
Chris Coutinho e7598a5467 format 2025-07-29 15:00:23 +02:00
Chris Coutinho fb6aa954b6 chore: ruff check 2025-07-29 09:11:25 +02:00
Chris Coutinho 02ad283a01 chore: format 2025-07-29 09:09:10 +02:00
Chris Coutinho 13ba9ef2e6 Merge remote-tracking branch 'origin/master' into refactor/server 2025-07-29 09:08:17 +02:00
github-actions[bot] 4767e88d2b bump: version 0.5.0 → 0.6.0 2025-07-29 05:40:28 +00:00
Chris Coutinho e38d0a8bdc Merge pull request #98 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to 81dc361
2025-07-29 07:40:13 +02:00
Chris Coutinho 1dca929983 Merge pull request #95 from neovasky/master
feat(calendar): add comprehensive Calendar app support via CalDAV protocol
2025-07-29 07:40:02 +02:00
renovate-bot-cbcoutinho[bot] 6a2bd4d274 chore(deps): update nextcloud:31.0.7 docker digest to 81dc361 2025-07-29 04:11:46 +00:00
Neovasky c91001d7e1 chore: refresh uv.lock file to fix CI/CD build issues
As requested by maintainer to resolve integration test failures
2025-07-28 22:56:07 -04:00
Neovasky 83748a27da fix: apply ruff formatting to pass CI checks
- Fixed line length issues in logger.warning calls
- Removed trailing spaces in docstrings
- Applied consistent formatting across all files
2025-07-28 11:52:10 -04:00
Neovasky 3ddeeab67f fix(calendar): address PR feedback from maintainer
- Remove CHANGELOG.md changes (auto-generated from commits)
- Move all parameter descriptions into function docstrings for LLM context
- Remove unused caldav dependency (using httpx for CalDAV implementation)
- Move datetime imports to top of modules
- Remove load_dotenv from tests/conftest.py
- Clarify Event vs Meeting distinction in docstrings
- Handle 401 auth errors gracefully in calendar tests

Addresses all feedback from PR #95 review
2025-07-28 11:44:53 -04:00
Chris Coutinho a2c78ee1ef test: Add tests for MCP tools and resources 2025-07-27 17:43:55 +02:00
Chris Coutinho 1e19061ee0 chore: Move tools into separate modules 2025-07-27 14:11:02 +02:00
Neovasky 2e078498b1 refactor(calendar): optimize logging for production readiness
- Change routine operation logs from info to debug level
- Simplify success messages for better readability
- Remove redundant calendar/path information from log messages
- Align logging style with repository standards

Following patterns established by repository maintainer in WebDAV client cleanup.
2025-07-27 00:46:57 -04:00
Neovasky 7291c930c4 feat(calendar): add comprehensive Calendar app support via CalDAV protocol
- Add complete CalDAV client implementation following NextCloud patterns
- Implement 11 comprehensive calendar MCP tools:
  * nc_calendar_list_calendars - list available calendars
  * nc_calendar_create_event - full event creation with recurrence, reminders, attendees
  * nc_calendar_list_events - enhanced with advanced filtering capabilities
  * nc_calendar_get_event - detailed event information retrieval
  * nc_calendar_update_event - comprehensive event modification
  * nc_calendar_delete_event - event removal
  * nc_calendar_create_meeting - quick meeting creation with smart defaults
  * nc_calendar_get_upcoming_events - upcoming events in next N days
  * nc_calendar_find_availability - intelligent scheduling with conflict detection
  * nc_calendar_bulk_operations - batch update/delete/move operations
  * nc_calendar_manage_calendar - calendar creation and management

- Add CalDAV and iCalendar dependencies to support calendar operations
- Implement comprehensive integration tests (11 test cases covering all scenarios)
- Update documentation with complete calendar tools reference and usage examples

Resolves #74
2025-07-27 00:25:31 -04:00
github-actions[bot] b8191c134a bump: version 0.4.1 → 0.5.0 2025-07-26 11:32:13 +00:00
Chris Coutinho 09061d9e4f Merge pull request #94 from cbcoutinho/fix/webdav
Update webdav client create_directory method to handle recursiv…
2025-07-26 13:31:50 +02:00
Chris Coutinho 2d3cb85fb2 Merge pull request #92 from neovasky/master
feat(webdav): add complete file system support
2025-07-26 13:28:12 +02:00
Chris Coutinho 3ad07d05dd feat: Update webdav client create_directory method to handle recursive directories 2025-07-26 13:27:21 +02:00
Neovasky 50c1215676 fix: apply ruff formatting to test_webdav_operations.py
- Fix quote style from single to double quotes
- Improve line breaks and spacing for better readability
- Address CI formatting requirements

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 02:33:21 -04:00
Neovasky bf5879d408 test: add comprehensive WebDAV integration tests
- Add 8 core WebDAV operation tests covering CRUD operations
- Add complex attachment cleanup test for category changes
- Fix ruff formatting violations in webdav.py and server.py
- Address PR feedback requirements for expanded WebDAV functionality

Tests focus on WebDAV client functionality and run locally with docker-compose.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 02:28:13 -04:00
Chris Coutinho 442e82e994 Merge pull request #88 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 25c0ae3
2025-07-25 11:05:54 +02:00
Neovasky 9e96999f02 feat(webdav): add complete file system support
- Add nc_webdav_list_directory tool for browsing any NextCloud directory
  - Add nc_webdav_read_file tool with automatic text/binary content handling
  - Add nc_webdav_write_file tool supporting text and base64 binary content
  - Add nc_webdav_create_directory tool for creating directories
  - Add nc_webdav_delete_resource tool for deleting files and directories
  - Extend WebDAV client beyond Notes attachments to general file operations
  - Add XML parsing for WebDAV PROPFIND responses with metadata extraction
  - Improve type annotations throughout codebase for better IDE support
  - Add comprehensive documentation with usage examples

  This transforms the NextCloud MCP server from a limited Notes/Tables tool
  into a full-featured file system interface, enabling complete NextCloud
  file management through LLM interactions.
2025-07-25 03:15:52 -04:00
Chris Coutinho e983693534 Merge pull request #90 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.3
2025-07-25 01:57:59 +02:00
renovate-bot-cbcoutinho[bot] b8a14a2229 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.3 2025-07-24 22:13:40 +00:00
Chris Coutinho 508f83dfad Merge pull request #89 from cbcoutinho/renovate/nextcloud-31.0.7
chore(deps): update nextcloud:31.0.7 docker digest to 31d564f
2025-07-24 14:22:55 +02:00
renovate-bot-cbcoutinho[bot] ce8d5f92b1 chore(deps): update nextcloud:31.0.7 docker digest to 31d564f 2025-07-24 04:11:59 +00:00
Chris Coutinho ca32ff39b8 Merge pull request #91 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to e92bafb
2025-07-24 01:38:53 +02:00
renovate-bot-cbcoutinho[bot] 9da53e51f0 chore(deps): update astral-sh/setup-uv digest to e92bafb 2025-07-23 22:14:26 +00:00
Chris Coutinho 2cbac7c4be Merge pull request #82 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.0
2025-07-18 23:28:51 +02:00
Chris Coutinho d2394465d7 Merge pull request #87 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 7edac99
2025-07-18 23:27:37 +02:00
renovate-bot-cbcoutinho[bot] c2615ac24d chore(deps): update astral-sh/setup-uv digest to 7edac99 2025-07-18 10:12:13 +00:00
renovate-bot-cbcoutinho[bot] 62e21f1f94 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.8.0 2025-07-18 04:14:53 +00:00
renovate-bot-cbcoutinho[bot] 9bd95a8b17 chore(deps): update redis:alpine docker digest to 25c0ae3 2025-07-17 22:08:58 +00:00
Chris Coutinho bfd2eed97b Merge pull request #85 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 2bcbaec
2025-07-16 23:21:42 +02:00
renovate-bot-cbcoutinho[bot] 8a0b964add chore(deps): update mariadb:lts docker digest to 2bcbaec 2025-07-16 16:05:48 +00:00
Chris Coutinho 59bab51090 Merge pull request #83 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to ee8fadc
2025-07-16 08:39:04 +02:00
Chris Coutinho 12fa550b60 Merge pull request #84 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to d12963a
2025-07-16 08:37:44 +02:00
renovate-bot-cbcoutinho[bot] 85cdf75a5b chore(deps): update redis:alpine docker digest to d12963a 2025-07-16 04:08:55 +00:00
renovate-bot-cbcoutinho[bot] 0ee2b5b034 chore(deps): update mariadb:lts docker digest to ee8fadc 2025-07-16 04:08:51 +00:00
Chris Coutinho 0c4d140bb9 Merge pull request #81 from cbcoutinho/renovate/nextcloud-31.x
chore(deps): update nextcloud docker tag to v31.0.7
2025-07-13 23:13:09 +02:00
renovate-bot-cbcoutinho[bot] f515d74a4d chore(deps): update nextcloud docker tag to v31.0.7 2025-07-12 04:05:32 +00:00
github-actions[bot] 79835b3439 bump: version 0.4.0 → 0.4.1 2025-07-10 17:35:22 +00:00
Chris Coutinho d518b76878 Merge pull request #64 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.10,<1.11
2025-07-10 19:34:58 +02:00
Chris Coutinho 5179db40db Merge pull request #79 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.20
2025-07-10 07:27:03 +02:00
renovate-bot-cbcoutinho[bot] 9cbeecae64 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.20 2025-07-09 22:06:38 +00:00
Chris Coutinho c5af81c94f Merge pull request #78 from cbcoutinho/cbcoutinho-patch-2
chore: Update README.md
2025-07-09 09:55:16 +02:00
Chris Coutinho ae966710a9 Update README.md 2025-07-09 09:54:58 +02:00
Chris Coutinho 9b14135dd3 Update README.md 2025-07-09 09:54:24 +02:00
Chris Coutinho 6f92cd8157 chore: Update README.md 2025-07-09 09:53:45 +02:00
Chris Coutinho 6545f8165f (chore) Update README.md 2025-07-09 00:36:02 +02:00
Chris Coutinho 4a742442fb Merge pull request #77 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 73734b0
2025-07-08 09:18:28 +02:00
renovate-bot-cbcoutinho[bot] f84144fcaa chore(deps): update redis:alpine docker digest to 73734b0 2025-07-07 22:04:16 +00:00
Chris Coutinho e09f373f84 Merge pull request #76 from cbcoutinho/refactor/clients
Move clients into separate submodule
2025-07-07 00:09:39 +02:00
Chris Coutinho e50be7db07 chore: Move clients into separate submodule 2025-07-07 00:06:24 +02:00
Chris Coutinho f03ab4ef55 chore: [skip ci] Remove tables-openapi.json 2025-07-06 09:53:33 +02:00
Chris Coutinho 3d26c6c145 Merge pull request #68 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 1e4ec03
2025-07-06 09:51:22 +02:00
Chris Coutinho a4b0c84f79 Merge pull request #67 from cbcoutinho/renovate/nextcloud-31.0.6
chore(deps): update nextcloud:31.0.6 docker digest to 588609d
2025-07-06 09:51:13 +02:00
Chris Coutinho e67e7c4246 Merge pull request #69 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.19
2025-07-06 09:51:05 +02:00
Chris Coutinho e0c4cc5d77 Merge pull request #70 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.3.0
2025-07-06 09:50:57 +02:00
github-actions[bot] b43ffad708 bump: version 0.3.0 → 0.4.0 2025-07-06 07:50:10 +00:00
Chris Coutinho cab7a59d2b Merge pull request #71 from cbcoutinho/feature/tables-app
Initialize Tables App
2025-07-06 09:49:45 +02:00
Chris Coutinho ca5bbb783a fix: update tests 2025-07-06 09:40:27 +02:00
Chris Coutinho d47e2bb8f0 test: Update tests with updated API 2025-07-06 09:37:31 +02:00
Chris Coutinho a1c186aa95 feat: Add TablesClient and associated tools 2025-07-06 09:18:34 +02:00
Chris Coutinho 57440f845f chore: Update pre-commit 2025-07-06 08:42:09 +02:00
Chris Coutinho a57c12591a chore: ruff format 2025-07-06 08:41:02 +02:00
Chris Coutinho 5b512f83bd refactor: Modularize NC and Notes app client 2025-07-06 08:39:28 +02:00
renovate-bot-cbcoutinho[bot] 4a2fd67e51 chore(deps): update hoverkraft-tech/compose-action action to v2.3.0 2025-07-05 13:12:44 +00:00
renovate-bot-cbcoutinho[bot] da3a0049a0 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.19 2025-07-05 13:12:37 +00:00
renovate-bot-cbcoutinho[bot] bb53ba6275 chore(deps): update nextcloud:31.0.6 docker digest to 588609d 2025-07-05 13:12:33 +00:00
renovate-bot-cbcoutinho[bot] 7a6c7c6efa chore(deps): update mariadb:lts docker digest to 1e4ec03 2025-07-05 13:12:28 +00:00
Chris Coutinho 266d2dac8d Merge pull request #66 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.17
2025-06-30 08:32:41 +02:00
renovate-bot-cbcoutinho[bot] d64c6e112e chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.17 2025-06-29 16:04:31 +00:00
Chris Coutinho 167517b95d Merge pull request #65 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.16
2025-06-29 00:20:35 +02:00
renovate-bot-cbcoutinho[bot] 33aa778713 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.16 2025-06-27 22:06:33 +00:00
renovate-bot-cbcoutinho[bot] 251c9aaae6 fix(deps): update dependency mcp to >=1.10,<1.11 2025-06-26 16:06:09 +00:00
Chris Coutinho ded48acd31 Merge pull request #63 from cbcoutinho/renovate/nextcloud-31.0.6
chore(deps): update nextcloud:31.0.6 docker digest to 0b133af
2025-06-26 14:01:55 +02:00
renovate-bot-cbcoutinho[bot] 0dacd84cc2 chore(deps): update nextcloud:31.0.6 docker digest to 0b133af 2025-06-26 10:07:22 +00:00
Chris Coutinho c0782dc69e Merge pull request #61 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.15
2025-06-26 09:44:21 +02:00
Chris Coutinho 4a8f9f7f7e chore: Update with "mergeConfidence:all-badges" 2025-06-26 09:43:59 +02:00
Chris Coutinho db9f2cad43 Merge pull request #62 from cbcoutinho/renovate/nextcloud-31.0.6
chore(deps): update nextcloud:31.0.6 docker digest to dff5690
2025-06-26 08:15:54 +02:00
renovate-bot-cbcoutinho[bot] d52860c86d chore(deps): update nextcloud:31.0.6 docker digest to dff5690 2025-06-26 04:06:33 +00:00
renovate-bot-cbcoutinho[bot] 4992f700c6 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.15 2025-06-25 16:06:41 +00:00
Chris Coutinho cc2777210b Merge pull request #60 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to bd01e18
2025-06-25 14:40:44 +02:00
renovate-bot-cbcoutinho[bot] ad1320319b chore(deps): update astral-sh/setup-uv digest to bd01e18 2025-06-25 10:08:27 +00:00
Chris Coutinho 9d9f1e1eaa Merge pull request #53 from cbcoutinho/renovate/nextcloud-31.x
chore(deps): update nextcloud docker tag to v31.0.6
2025-06-24 14:44:13 +02:00
Chris Coutinho 7b3b624403 Merge pull request #59 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.14
2025-06-24 14:44:04 +02:00
renovate-bot-cbcoutinho[bot] 5c908bf8d2 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.14 2025-06-24 12:28:24 +00:00
Chris Coutinho fe16f4db54 Merge pull request #58 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to 445689e
2025-06-20 07:26:00 +02:00
renovate-bot-cbcoutinho[bot] 7b10296058 chore(deps): update astral-sh/setup-uv digest to 445689e 2025-06-19 22:09:17 +00:00
Chris Coutinho e6890ab24d Merge pull request #57 from cbcoutinho/renovate/astral-sh-setup-uv-digest
chore(deps): update astral-sh/setup-uv digest to a02a550
2025-06-19 13:40:18 +02:00
renovate-bot-cbcoutinho[bot] cf49866a87 chore(deps): update astral-sh/setup-uv digest to a02a550 2025-06-18 22:12:49 +00:00
Chris Coutinho d8e7d0b465 Merge pull request #55 from cbcoutinho/renovate/docker-setup-buildx-action-digest
chore(deps): update docker/setup-buildx-action digest to e468171
2025-06-18 22:27:27 +02:00
renovate-bot-cbcoutinho[bot] c336c5d2a2 chore(deps): update docker/setup-buildx-action digest to e468171 2025-06-18 10:11:18 +00:00
Chris Coutinho 45c0622459 Merge pull request #56 from lwsinclair/add-mseep-badge
Add MseeP.ai badge
2025-06-17 15:16:34 +02:00
Lawrence Sinclair 7dfbe9dd62 Add MseeP.ai badge to README.md 2025-06-17 12:15:29 +07:00
renovate-bot-cbcoutinho[bot] 3d5da56d83 chore(deps): update nextcloud docker tag to v31.0.6 2025-06-14 04:11:21 +00:00
Chris Coutinho 2b1dbfef39 Merge pull request #51 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to 3aed4aa
2025-06-13 11:58:37 +02:00
Chris Coutinho 2e016080fd Merge pull request #52 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.13
2025-06-13 11:58:27 +02:00
renovate-bot-cbcoutinho[bot] e0a966b4a6 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.13 2025-06-12 22:12:42 +00:00
renovate-bot-cbcoutinho[bot] 07a8b6e704 chore(deps): update nextcloud:31.0.5 docker digest to 3aed4aa 2025-06-12 22:12:38 +00:00
Chris Coutinho 659da9a770 Merge pull request #50 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to 21780a1
2025-06-11 18:12:11 +02:00
renovate-bot-cbcoutinho[bot] 18f8b73982 chore(deps): update nextcloud:31.0.5 docker digest to 21780a1 2025-06-11 16:07:25 +00:00
Chris Coutinho 2bc0988e8d Merge pull request #48 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 1e66902
2025-06-11 08:51:27 +02:00
Chris Coutinho 74235ed8bb Merge pull request #49 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to f43cee6
2025-06-11 08:51:20 +02:00
Chris Coutinho 89a9af7c25 Merge pull request #47 from cbcoutinho/renovate/softprops-action-gh-release-digest
chore(deps): update softprops/action-gh-release digest to 72f2c25
2025-06-11 08:51:10 +02:00
renovate-bot-cbcoutinho[bot] d247a07643 chore(deps): update softprops/action-gh-release digest to 72f2c25 2025-06-11 04:07:40 +00:00
renovate-bot-cbcoutinho[bot] 794d4184d2 chore(deps): update nextcloud:31.0.5 docker digest to f43cee6 2025-06-11 04:07:35 +00:00
renovate-bot-cbcoutinho[bot] cc17b28eab chore(deps): update mariadb:lts docker digest to 1e66902 2025-06-11 04:07:30 +00:00
Chris Coutinho 5626f6fd6f Merge pull request #46 from cbcoutinho/renovate/softprops-action-gh-release-digest
chore(deps): update softprops/action-gh-release digest to d5382d3
2025-06-10 09:10:37 +02:00
renovate-bot-cbcoutinho[bot] 79a466d16c chore(deps): update softprops/action-gh-release digest to d5382d3 2025-06-10 04:07:22 +00:00
Chris Coutinho 6aa06b4c9d Merge pull request #45 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.12
2025-06-07 07:35:17 +02:00
Chris Coutinho c993872ab5 Merge pull request #44 from cbcoutinho/renovate/nextcloud-31.0.5
chore(deps): update nextcloud:31.0.5 docker digest to e775d46
2025-06-07 07:35:08 +02:00
renovate-bot-cbcoutinho[bot] e69819a49b chore(deps): update nextcloud:31.0.5 docker digest to e775d46 2025-06-07 04:06:37 +00:00
renovate-bot-cbcoutinho[bot] 49868d2bb5 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.12 2025-06-06 22:05:10 +00:00
github-actions[bot] 33c8623d5c bump: version 0.2.5 → 0.3.0 2025-06-06 17:20:50 +00:00
Chris Coutinho 150e656a36 Merge pull request #43 from cbcoutinho/feature/async
Switch to using async client
2025-06-06 19:20:25 +02:00
Chris Coutinho 2708d708b0 chore: ruff check --fix 2025-06-06 19:11:48 +02:00
Chris Coutinho c1e3a6aeaa chore: ruff format 2025-06-06 19:11:31 +02:00
Chris Coutinho 5ee9435741 test: Update tests with async 2025-06-06 19:10:10 +02:00
Chris Coutinho 110df3d7b9 chore: ruff check --fix 2025-06-06 18:44:09 +02:00
Chris Coutinho fd61c2de56 chore: format 2025-06-06 18:43:32 +02:00
Chris Coutinho ee32a1bfe8 feat: Switch to using async client 2025-06-06 18:41:57 +02:00
Chris Coutinho c918284927 Merge pull request #42 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.11
2025-06-05 00:53:46 +02:00
renovate-bot-cbcoutinho[bot] 98586a3684 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.11 2025-06-04 22:06:06 +00:00
Chris Coutinho 7e02527531 Merge pull request #41 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.10
2025-06-04 23:00:57 +02:00
renovate-bot-cbcoutinho[bot] 60af7ae255 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.10 2025-06-03 22:08:09 +00:00
Chris Coutinho 2437d5fb12 Merge pull request #40 from cbcoutinho/renovate/mariadb-lts
chore(deps): update mariadb:lts docker digest to 1d18f91
2025-06-03 12:30:16 +02:00
renovate-bot-cbcoutinho[bot] 615d27a9c9 chore(deps): update mariadb:lts docker digest to 1d18f91 2025-06-03 10:06:28 +00:00
Chris Coutinho 088f6aec3f Merge pull request #39 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.9
2025-05-31 09:58:32 +02:00
renovate-bot-cbcoutinho[bot] 80c55d5bdc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.7.9 2025-05-30 22:14:05 +00:00
Chris Coutinho 63ccc9dc6c Merge pull request #38 from cbcoutinho/renovate/redis-alpine
chore(deps): update redis:alpine docker digest to 48501c5
2025-05-30 13:41:06 +02:00
renovate-bot-cbcoutinho[bot] ec81f932ee chore(deps): update redis:alpine docker digest to 48501c5 2025-05-30 08:32:17 +00:00
Chris Coutinho 88e6e865f6 Merge pull request #37 from cbcoutinho/renovate/docker-build-push-action-digest
chore(deps): update docker/build-push-action digest to 2634353
2025-05-28 17:01:22 +02:00
renovate-bot-cbcoutinho[bot] e6a5e235ea chore(deps): update docker/build-push-action digest to 2634353 2025-05-27 22:07:43 +00:00
Chris Coutinho 85a5014479 Merge pull request #36 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin astral-sh/setup-uv action to f0ec1fc
2025-05-25 18:10:39 +02:00
renovate-bot-cbcoutinho[bot] 14da0f2451 chore(deps): pin astral-sh/setup-uv action to f0ec1fc 2025-05-25 16:05:38 +00:00
github-actions[bot] dfa0d50497 bump: version 0.2.4 → 0.2.5 2025-05-25 10:55:21 +00:00
Chris Coutinho 266c8bf90d Merge pull request #35 from cbcoutinho/fix/release
Commitizen release process
2025-05-25 12:55:00 +02:00
Chris Coutinho 2b5bb1cc81 fix: Commitizen release process
https://commitizen-tools.github.io/commitizen/tutorials/github_actions/
2025-05-25 12:47:10 +02:00
github-actions[bot] 847a69e2ba bump: version 0.2.3 → 0.2.4 2025-05-25 10:33:45 +00:00
Chris Coutinho 186d2c1d94 Merge pull request #34 from cbcoutinho/feature/logging
fix: Configure logging
2025-05-25 12:33:20 +02:00
Chris Coutinho 96d5789200 fix: Do not update dependencies when running in Dockerfile 2025-05-25 12:26:01 +02:00
Chris Coutinho b332c54330 ci: Setup uv using action, make sure uv runs tests without updating 2025-05-25 12:02:57 +02:00
Chris Coutinho 9a05b171ae ci: Install uv using curl 2025-05-25 11:55:21 +02:00
Chris Coutinho e93eb9d302 fix: Configure logging 2025-05-25 11:46:41 +02:00
Chris Coutinho 5af7c25dab Merge branch 'master' of github.com:cbcoutinho/nextcloud-mcp-server 2025-05-25 10:59:26 +02:00
Chris Coutinho a0b9482915 build: Only build on tags 2025-05-25 10:59:14 +02:00
132 changed files with 32016 additions and 1977 deletions
+1 -2
View File
@@ -1,8 +1,7 @@
*
!pyproject.toml
!poetry.lock
!README.md
!uv.lock
!nextcloud_mcp_server/
!nextcloud_mcp_server/**/*.py
+4 -4
View File
@@ -15,17 +15,17 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
token: "${{ secrets.GITHUB_TOKEN }}"
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Create bump and changelog
uses: commitizen-tools/commitizen-action@5b0848cd060263e24602d1eba03710e056ef7711 # 0.24.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
+5 -6
View File
@@ -2,7 +2,6 @@ name: Build and Publish Docker Image
on:
push:
branches: [ "master" ]
tags: ["*"]
jobs:
@@ -13,11 +12,11 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Docker meta
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
with:
# list of Docker images to use as base name for tags
images: |
@@ -34,18 +33,18 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # 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@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+29
View File
@@ -0,0 +1,29 @@
name: Release
on:
push:
tags:
- v*
jobs:
pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
# Environment and permissions trusted publishing.
environment:
# Create this environment in the GitHub repository under Settings -> Environments
name: pypi
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
run: uv build
- name: Publish
run: uv publish
+24 -10
View File
@@ -6,24 +6,38 @@ on:
- 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@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Run docker compose
uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
with:
compose-file: "./docker-compose.yml"
up-flags: "--build"
- name: Install dependencies
- name: Install the latest version of uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- name: Install Playwright dependencies
run: |
sudo apt update -y && sudo apt install -y pipx
pipx install uv
uv sync --locked
env:
DEBIAN_FRONTEND: "noninteractive"
uv run playwright install chromium --with-deps
- name: Wait for service to be ready
run: |
@@ -48,4 +62,4 @@ jobs:
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
uv run python -m pytest
uv run pytest -v --log-level=INFO
+7
View File
@@ -1,2 +1,9 @@
__pycache__/
.coverage
.env
*.env
.env.local
.env.*.local
# Generated by pytest used to login users
.nextcloud_oauth_*.json
+15 -3
View File
@@ -1,8 +1,20 @@
repos:
- hooks:
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.9.0
hooks:
- id: commitizen
- id: commitizen-branch
stages:
- pre-push
repo: https://github.com/commitizen-tools/commitizen
rev: v4.8.2
- 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]
+273
View File
@@ -1,3 +1,276 @@
## v0.17.0 (2025-10-19)
### Feat
- **caldav**: Add support for tasks
### Fix
- **caldav**: Check that calendar exists after creation to avoid race condition
- **caldav**: Properly parse datetimes as vDDDTypes
### Refactor
- Migrate from internal CalendarClient to caldav library
## v0.16.0 (2025-10-19)
### Feat
- **webdav**: Add search and list favorite response tools
### Perf
- **notes**: Improve notes search performance using async iterators
## v0.15.2 (2025-10-17)
### Refactor
- Unify logging & remove factory deployment
## v0.15.1 (2025-10-17)
### Fix
- Increase HTTP client timeout to 30s
- Handle RequestError in mcp tools
## v0.15.0 (2025-10-17)
### Feat
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
## v0.14.3 (2025-10-17)
### Fix
- **deps**: update dependency mcp to >=1.18,<1.19
## v0.14.2 (2025-10-16)
### Fix
- **deps**: update dependency pillow to v12
## v0.14.1 (2025-10-15)
### Fix
- **oauth**: Remove the option to force_register new clients
## v0.14.0 (2025-10-15)
### Feat
- Add Groups API client
- add sharing API client and server tools
- **users**: Initialize user API client
### Fix
- Update user/groups API to OCS v2
## v0.13.0 (2025-10-13)
### Feat
- **server**: Experimental support for OAuth2/OIDC authentication
## v0.12.6 (2025-10-11)
### Fix
- **deps**: update dependency mcp to >=1.17,<1.18
## v0.12.5 (2025-10-03)
### Fix
- **deps**: update dependency mcp to >=1.16,<1.17
## v0.12.4 (2025-09-25)
### Fix
- **deps**: update dependency mcp to >=1.15,<1.16
## v0.12.3 (2025-09-23)
### Refactor
- Add tools for all resources to enable tool-only workflows
## v0.12.2 (2025-09-20)
### Refactor
- Add `http` to --transport option
## 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
+254
View File
@@ -0,0 +1,254 @@
# 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"
```
### Load Testing
```bash
# Run benchmark with default settings (10 workers, 30 seconds)
uv run python -m tests.load.benchmark
# Quick test with custom concurrency and duration
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
# Extended load test (50 workers for 5 minutes)
uv run python -m tests.load.benchmark -c 50 -d 300
# Export results to JSON for analysis
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
# Test OAuth server on port 8001
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
# Verbose mode with detailed logging
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
```
**Load Testing Features:**
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
- **Real-time progress** bar with live RPS and error counts
- **Detailed metrics**:
- Throughput (requests/second)
- Latency percentiles (p50, p90, p95, p99)
- Per-operation breakdown
- Error rates and types
- **Automatic cleanup** of test data
- **JSON export** for CI/CD integration
- **Server health checks** before starting
**Understanding Results:**
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
- **Latency**:
- p50 (median): Should be <100ms for most operations
- p95: Should be <500ms
- p99: Should be <1000ms
- **Error Rate**: Should be <1% under normal load
**Common Bottlenecks:**
1. Nextcloud backend API response times (most common)
2. Database connection limits
3. HTTP client connection pooling
4. Network I/O between containers
### 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 the appropriate MCP server container:
# For basic auth changes (most common) - uses admin credentials
docker-compose up --build -d mcp
# For OAuth changes - uses OAuth authentication flow
docker-compose up --build -d mcp-oauth
# Build Docker image
docker build -t nextcloud-mcp-server .
```
**Important: Two MCP Server Containers**
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
- **`mcp-oauth`** (port 8001): Uses OAuth authentication. Only use this when working on OAuth-specific features or tests.
### 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, and **tasks (VTODO)**
- **Calendar Operations**: List, create, delete calendars
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
- Priority levels (0-9, 1=highest, 9=lowest)
- Due dates, start dates, completion tracking
- Percent complete (0-100%)
- Categories and filtering
- Search across all calendars
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
- **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
### MCP Response Patterns
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
**Pattern:**
1. Client methods return `List[Dict]` (raw data)
2. MCP tools convert to Pydantic models and wrap in response object
3. Response models inherit from `BaseResponse`, include `results` field + metadata
**Reference implementations:**
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
### Testing Structure
- **Integration tests** in `tests/client/` and `tests/server/` - 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:
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
#### 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
- **Rebuild the correct container** after code changes:
- For basic auth tests (most common): `docker-compose up --build -d mcp`
- For OAuth tests: `docker-compose up --build -d mcp-oauth`
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
- `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/server/test_mcp.py -k "notes" -v`
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
#### OAuth/OIDC Testing
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
**OAuth Testing Setup:**
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
- Stored in `.nextcloud_oauth_shared_test_client.json`
- Matches production MCP server behavior
- Each user gets their own unique access token
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
**Example Commands:**
```bash
# Run all OAuth tests with Playwright automation using Firefox
uv run pytest tests/server/test_oauth*.py --browser firefox -v
# Run specific tests with visible browser for debugging
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
# Run with Chromium (default)
uv run pytest tests/server/test_oauth*.py -v
```
**Test Environment:**
- **Two MCP server containers are available:**
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
**CI/CD Notes:**
- Playwright tests run in CI/CD environments
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
### Configuration Files
- **`pyproject.toml`** - Python project configuration using uv for dependency management
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
+7 -6
View File
@@ -1,13 +1,14 @@
FROM ghcr.io/astral-sh/uv:0.7.8-python3.11-alpine@sha256:e7a2eb4196da4b1cc8c746c3fd7209b8c3682aeb679b87e63382c9e2000a9b29
FROM ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine@sha256:1a51c7710eaf839fa3365329ad993b48d17ddd9ab0f0672efaa9b09f407ebf44
# Install git (required for caldav dependency from git)
RUN apk add --no-cache git
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
ENV PYTHONUNBUFFERED=1
CMD ["mcp", "run", "--transport", "sse", "nextcloud_mcp_server/server.py:mcp"]
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
+242 -80
View File
@@ -2,119 +2,281 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models (LLMs) like OpenAI's GPT, Google's Gemini, or Anthropic's Claude to interact with your Nextcloud instance. This enables automation of various Nextcloud actions, starting with the Notes API.
**Enable AI assistants to interact with your Nextcloud instance.**
## Features
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
Currently, the server primarily interacts with the Nextcloud Notes API, providing tools and resources to manage notes.
> [!NOTE]
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support.
### Available Tools
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
* `nc_notes_create_note`: Create a new note.
* `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.
* `nc_get_note`: Get a specific note by ID.
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|--------|---------------------------------------------|--------------------------------------------------------|
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
### Available Resources
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
* `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.
* `nc://Notes/{note_id}/attachments/{attachment_filename}`: Access attachments for notes.
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
### Note Attachments
### Authentication
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
| Mode | Security | Best For |
|------|----------|----------|
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
| **Basic Auth** ✅ | Lower | Development, testing, production |
* 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.
> [!IMPORTANT]
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
> - **Production use**: Wait for upstream patch to be merged into official releases
>
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
## Installation
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
### Prerequisites
## Quick Start
* Python 3.8+
* Access to a Nextcloud instance
### Local Installation
1. Clone the repository (if running from source):
```bash
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
```
2. Install the package (if running as a library):
```bash
poetry install
```
### Docker
A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server`
## Configuration
The server requires credentials to connect to your Nextcloud instance. Create a file named `.env` (or any name you prefer) in the directory where you'll run the server, based on the `env.sample` file:
```dotenv
# .env
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_USERNAME=your_nextcloud_username
NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
```
* `NEXTCLOUD_HOST`: The full URL of your Nextcloud instance.
* `NEXTCLOUD_USERNAME`: Your Nextcloud username.
* `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure.
## Running the Server
### Locally
Ensure your environment variables are loaded, then run the server using `mcp run`:
### 1. Install
```bash
# Load environment variables from your .env file
# Clone the repository
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
# Install with uv (recommended)
uv sync
# Or using Docker
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
See [Installation Guide](docs/installation.md) for detailed instructions.
### 2. Configure
Create a `.env` file:
```bash
# Copy the sample
cp env.sample .env
```
**For Basic Auth (recommended for most users):**
```dotenv
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_USERNAME=your_username
NEXTCLOUD_PASSWORD=your_app_password
```
**For OAuth (experimental - requires patches):**
```dotenv
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
```
See [Configuration Guide](docs/configuration.md) for all options.
### 3. Set Up Authentication
**Basic Auth Setup (recommended):**
1. Create an app password in Nextcloud (Settings → Security → Devices & sessions)
2. Add credentials to `.env` file
3. Start the server
**OAuth Setup (experimental):**
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
3. Enable dynamic client registration or create an OIDC client with id & secret
4. Configure Bearer token validation in `user_oidc`
5. Start the server
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
### 4. Run the Server
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run the server
mcp run --transport sse nextcloud_mcp_server.server:mcp
# Start with Basic Auth (default)
uv run nextcloud-mcp-server
# Or start with OAuth (experimental - requires patches)
uv run nextcloud-mcp-server --oauth
# Or with Docker
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
The server will start, typically listening on `http://0.0.0.0:8000`.
The server starts on `http://127.0.0.1:8000` by default.
### Using Docker
See [Running the Server](docs/running.md) for more options.
Mount your environment file when running the container:
### 5. Connect an MCP Client
Test with MCP Inspector:
```bash
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
uv run mcp dev
```
This will start the server and expose it on port 8000 of your local machine.
Or connect from:
- Claude Desktop
- Any MCP-compatible client
## Usage
## Documentation
Once the server is running, you can connect to it using an MCP client like `uvx`. Add the server to your `uvx` configuration:
### Getting Started
- **[Installation](docs/installation.md)** - Install the server
- **[Configuration](docs/configuration.md)** - Environment variables and settings
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
- **[Running the Server](docs/running.md)** - Start and manage the server
### Architecture
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent
### OAuth Documentation (Experimental)
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works
- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
### Reference
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
### App-Specific Documentation
- [Notes API](docs/notes.md)
- [Calendar (CalDAV)](docs/calendar.md)
- [Contacts (CardDAV)](docs/contacts.md)
- [Cookbook](docs/cookbook.md)
- [Deck](docs/deck.md)
- [Tables](docs/table.md)
- [WebDAV](docs/webdav.md)
## MCP Tools & Resources
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
### Tools
Tools enable AI assistants to perform actions:
- `nc_notes_create_note` - Create a new note
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
- `deck_create_card` - Create a Deck card
- `nc_calendar_create_event` - Create a calendar event
- `nc_contacts_create_contact` - Create a contact
- And many more...
### Resources
Resources provide read-only access to Nextcloud data:
- `nc://capabilities` - Server capabilities
- `cookbook://version` - Cookbook app version info
- `nc://Deck/boards/{board_id}` - Deck board data
- `notes://settings` - Notes app settings
- And more...
Run `uv run nextcloud-mcp-server --help` to see all available options.
## Examples
### Create a Note
```
AI: "Create a note called 'Meeting Notes' with today's agenda"
→ Uses nc_notes_create_note tool
```
### Manage Recipes
```
AI: "Import the recipe from this URL: https://www.example.com/recipe/chocolate-cake"
→ Uses nc_cookbook_import_recipe tool to extract schema.org metadata
```
### Manage Calendar
```
AI: "Schedule a team meeting for next Tuesday at 2pm"
→ Uses nc_calendar_create_event tool
```
### Organize Files
```
AI: "Create a folder called 'Project X' and move all PDFs there"
→ Uses WebDAV tools (nc_webdav_create_directory, nc_webdav_move)
```
### Project Management
```
AI: "Create a new Deck board for Q1 planning with Todo, In Progress, and Done stacks"
→ Uses deck_create_board and deck_create_stack tools
```
## Transport Protocols
The server supports multiple MCP transport protocols:
- **streamable-http** (recommended) - Modern streaming protocol
- **sse** (default, deprecated) - Server-Sent Events for backward compatibility
- **http** - Standard HTTP protocol
```bash
uvx mcp add nextcloud-mcp http://localhost:8000 --default-transport sse
# Use streamable-http (recommended)
uv run nextcloud-mcp-server --transport streamable-http
```
You can then interact with the server's tools and resources through your LLM interface connected to `uvx`.
## References:
- https://github.com/modelcontextprotocol/python-sdk
> [!WARNING]
> SSE transport is deprecated and will be removed in a future MCP specification version. Please migrate to `streamable-http`.
## Contributing
Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server).
Contributions are welcome!
- Report bugs or request features: [GitHub Issues](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
- Submit improvements: [Pull Requests](https://github.com/cbcoutinho/nextcloud-mcp-server/pulls)
- Read [CLAUDE.md](CLAUDE.md) for development guidelines
## Security
[![MseeP.ai Security Assessment](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
This project takes security seriously:
- OAuth2/OIDC support (experimental - requires upstream patches)
- Basic Auth with app-specific passwords (recommended)
- No credential storage with OAuth mode
- Per-user access tokens
- Regular security assessments
Found a security issue? Please report it privately to the maintainers.
## License
This project is licensed under the MIT License. See the LICENSE file for details.
This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) for details.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=cbcoutinho/nextcloud-mcp-server&type=Date)](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date)
## References
- [Model Context Protocol](https://github.com/modelcontextprotocol)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [Nextcloud](https://nextcloud.com/)
@@ -0,0 +1,69 @@
From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001
From: Chris Coutinho <chris@coutinho.io>
Date: Sun, 12 Oct 2025 21:09:29 +0200
Subject: [PATCH 1/1] Fix Bearer token authentication causing session logout
When using Bearer token authentication with OIDC, API requests to
endpoints with @CORS annotations (like Notes API) were failing with
401 Unauthorized errors. This occurred because:
1. Bearer token validation successfully authenticated the user
2. A session was created for the authenticated user
3. Nextcloud's CORSMiddleware detected the logged-in session but no
CSRF token, causing it to call session->logout()
4. The logout invalidated the session, breaking the API request
This fix sets the 'app_api' session flag during Bearer token
authentication, which instructs CORSMiddleware to skip the CSRF check
and logout logic. This is the same mechanism used by Nextcloud's
AppAPI framework for external application authentication.
The flag is set at all successful Bearer token authentication points:
- Line 243: After OIDC Identity Provider validation
- Line 310: After auto-provisioning with bearer provisioning
- Line 315: After existing user authentication
- Line 337: After LDAP user sync
Fixes: Bearer token authentication for all Nextcloud APIs
Tested-with: nextcloud-mcp-server integration tests
Signed-off-by: Chris Coutinho <chris@coutinho.io>
---
lib/User/Backend.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/lib/User/Backend.php b/lib/User/Backend.php
index 23cfb18..65665cc 100644
--- a/lib/User/Backend.php
+++ b/lib/User/Backend.php
@@ -240,6 +240,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
$this->eventDispatcher->dispatchTyped($validationEvent);
$oidcProviderUserId = $validationEvent->getUserId();
if ($oidcProviderUserId !== null) {
+ $this->session->set('app_api', true);
return $oidcProviderUserId;
} else {
$this->logger->debug('[NextcloudOidcProviderValidator] The bearer token validation has failed');
@@ -306,10 +307,12 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
}
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $userId;
} elseif ($this->userExists($tokenUserId)) {
$this->checkFirstLogin($tokenUserId);
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $tokenUserId;
} else {
// check if the user exists locally
@@ -331,6 +334,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
}
$this->checkFirstLogin($tokenUserId);
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $tokenUserId;
}
}
--
2.51.0
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring Calendar app..."
# Enable calendar app
php /var/www/html/occ app:enable calendar
php /var/www/html/occ app:enable --force tasks # Not currently supported on 32
# Wait for calendar app to be fully initialized
echo "Waiting for calendar app to initialize..."
sleep 5
# Disable rate limits on calendar creation for integration tests
# Set to -1 to completely disable rate limiting
# Reference: https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html#rate-limits
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
php occ config:app:set dav maximumCalendarsSubscriptions --type=integer --value=-1
# 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!"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable contacts
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable cookbook
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable deck
@@ -1,3 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable notes
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring OIDC apps for testing..."
# Enable the OIDC Identity Provider app
php /var/www/html/occ app:enable oidc
# Enable the user_oidc app (OIDC client for bearer token validation)
php /var/www/html/occ app:enable user_oidc
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
# Configure OIDC Identity Provider with dynamic client registration enabled
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
echo "OIDC apps installed and configured successfully"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable tables
+34 -9
View File
@@ -3,7 +3,7 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: mariadb:lts@sha256:c5c82ddc074b9236fda87127934a1da726ecac68e9de083fd0e0d11a7bedc077
image: docker.io/library/mariadb:lts@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -17,18 +17,14 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis
redis:
image: redis:alpine@sha256:62b5498c91778f738f0efbf0a6fd5b434011235a3e7b5f2ed4a2c0c63bb1c786
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
restart: always
app:
image: nextcloud:31.0.5@sha256:3f71577339ef1db0d1900c8574853d11fa7100452bf24f0a06fae5d9ee019cb4
#user: www-data:www-data
image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4
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
- 0.0.0.0:8080:80
depends_on:
- redis
- db
@@ -43,16 +39,45 @@ services:
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
recipes:
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
mcp:
build: .
command: ["--transport", "streamable-http"]
restart: always
depends_on:
- app
ports:
- 8000:8000
- 127.0.0.1:8000:8000
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_USERNAME=admin
- NEXTCLOUD_PASSWORD=admin
mcp-oauth:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
restart: always
depends_on:
- app
ports:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
# No USERNAME/PASSWORD - will use OAuth
volumes:
- oauth-client-storage:/app/.oauth
volumes:
nextcloud:
db:
oauth-client-storage:
+161
View File
@@ -0,0 +1,161 @@
# Authentication
The Nextcloud MCP server supports two authentication modes for connecting to your Nextcloud instance.
## Authentication Modes Comparison
| Mode | Status | Security | Use Case |
|------|--------|----------|----------|
| **OAuth2/OIDC** | ✅ Recommended | 🔒 High | Production deployments, multi-user scenarios |
| **Basic Auth** | ⚠️ Legacy | ⚠️ Lower | Development, backward compatibility |
## OAuth2/OIDC (Recommended)
OAuth2/OIDC authentication provides secure, token-based authentication following modern security standards.
### Architecture
The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources:
```
MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs)
OAuth Flow with PKCE Bearer Token Auth
```
**Key Components**:
- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools)
- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens)
- **Nextcloud `user_oidc` app**: Token validation middleware
- **MCP Client**: Any MCP-compatible client (Claude, custom clients)
For detailed architecture, see [OAuth Architecture](oauth-architecture.md).
### Required Nextcloud Apps
OAuth authentication requires **two Nextcloud apps** to work together:
#### 1. `oidc` - OIDC Identity Provider
**Purpose:** Makes Nextcloud an OAuth2/OIDC authorization server
**Provides:**
- OAuth2 authorization endpoint (`/apps/oidc/authorize`)
- Token endpoint (`/apps/oidc/token`)
- User info endpoint (`/apps/oidc/userinfo`)
- JWKS endpoint for token validation (`/apps/oidc/jwks`)
- Dynamic client registration endpoint (`/apps/oidc/register`)
**Installation:** Available in Nextcloud App Store under "Security"
#### 2. `user_oidc` - OpenID Connect User Backend
**Purpose:** Authenticates users and validates Bearer tokens
**Provides:**
- Bearer token validation against the OIDC provider
- User authentication via OIDC
- Session management for authenticated users
**Installation:** Available in Nextcloud App Store under "Security"
**Important:** The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (like Notes API). See [Upstream Status](oauth-upstream-status.md) for details.
### Benefits
- **Zero-config deployment** via dynamic client registration
- **No credential storage** in environment variables
- **Per-user authentication** with access tokens
- **Per-user permissions** - each user has their own Nextcloud client
- **Automatic token validation** via Nextcloud OIDC userinfo endpoint
- **Token caching** for performance (default: 1 hour TTL)
- **PKCE required** for enhanced security (S256 code challenge)
- **Secure by design** following OAuth 2.0 and OpenID Connect standards
### Current Implementation Limitations
> [!IMPORTANT]
> **Tested Configuration:**
> - ✅ Nextcloud `oidc` app (OIDC Identity Provider) + `user_oidc` app (OIDC User Backend)
> - ✅ Nextcloud acting as its own identity provider (self-hosted OIDC)
> - ✅ MCP server as OAuth Resource Server
> - ✅ PKCE with S256 code challenge method
>
> **Not Tested:**
> - ❌ External identity providers (Azure AD, Keycloak, Okta, etc.)
> - ❌ Using `user_oidc` with external OIDC providers
>
> **Known Requirements:**
> - 🔧 The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (see [Upstream Status](oauth-upstream-status.md))
> - ⏱️ Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production
> - 🔐 PKCE must be advertised in OIDC discovery (see [Upstream Status](oauth-upstream-status.md))
### How OAuth Works
The MCP server implements the OAuth 2.0 Resource Server pattern:
**Phase 1: Authorization (OAuth Flow with PKCE)**
1. MCP client connects and receives OAuth settings (issuer URL, scopes)
2. Client initiates OAuth flow with PKCE (Proof Key for Code Exchange)
3. User authenticates via browser to Nextcloud
4. Nextcloud redirects back with authorization code
5. Client exchanges code + code_verifier for access token
**Phase 2: API Access (Bearer Token Validation)**
6. Client sends MCP requests with `Authorization: Bearer <token>` header
7. MCP server validates token by calling Nextcloud's userinfo endpoint
8. Server creates per-user NextcloudClient instance with the token
9. All Nextcloud API requests use the user's Bearer token
10. User-specific permissions and audit trails apply
This ensures:
- Each user has their own authenticated session
- Actions appear from the correct user in Nextcloud logs
- Proper permission boundaries are maintained
- No shared credentials between users
### See Also
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed production setup
- [OAuth Architecture](oauth-architecture.md) - Technical details
- [Upstream Status](oauth-upstream-status.md) - Required patches and PR status
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific issues
- [Configuration](configuration.md) - Environment variables
## Basic Authentication (Legacy)
Basic Authentication uses username and password credentials directly.
### Benefits
- **Simple setup** with username/password
- **Single-user** server instances
- **Quick for development** and testing
### Limitations
- **Credentials in environment** (less secure)
- **Single user only** - all requests use the same account
- **No audit trail** - all actions appear from the same user
- **Maintained for compatibility** - will be deprecated in future versions
> [!WARNING]
> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. It's maintained for backward compatibility only and may be deprecated in future versions. Use OAuth for production deployments.
### See Also
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
## Mode Detection
The server automatically detects the authentication mode:
- **OAuth mode**: When `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD` are NOT set
- **BasicAuth mode**: When both username and password are provided
You can also force a specific mode using CLI flags:
```bash
# Force OAuth mode
uv run nextcloud-mcp-server --oauth
# Force BasicAuth mode
uv run nextcloud-mcp-server --no-oauth
```
## Switching Between Modes
See [Troubleshooting: Switching Between OAuth and BasicAuth](troubleshooting.md#switching-between-oauth-and-basicauth) for instructions.
+109
View File
@@ -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"
)
```
+698
View File
@@ -0,0 +1,698 @@
# MCP Server Comparison: Nextcloud MCP Server vs Context Agent
This document compares the two MCP server implementations in the Nextcloud ecosystem:
1. **Nextcloud MCP Server** (this project) - Standalone MCP server for external access to Nextcloud
2. **Context Agent MCP Server** - MCP server embedded within Nextcloud as an External App
## Executive Summary
Both projects expose Nextcloud functionality via the Model Context Protocol (MCP), but serve different purposes and audiences:
- **Nextcloud MCP Server**: Brings Nextcloud OUT to external MCP clients (Claude Code, etc.)
- **Context Agent**: Brings external MCP servers IN to Nextcloud's AI Assistant
## Architecture Overview
```mermaid
graph TB
subgraph External["External Clients"]
CC[Claude Code]
IDE[IDEs with MCP]
APP[Other MCP Clients]
end
subgraph NMCP["Nextcloud MCP Server<br/>(This Project)"]
NMCP_Server[FastMCP Server]
NMCP_Client[HTTP Clients]
NMCP_Auth[OAuth/BasicAuth]
end
subgraph NC["Nextcloud Instance"]
subgraph CA["Context Agent ExApp"]
CA_Agent[LangGraph Agent]
CA_MCP[MCP Server /mcp]
CA_Tools[Tool Loader]
end
NC_Apps[Nextcloud Apps<br/>Notes, Calendar, Files, etc.]
NC_Assistant[Assistant App]
end
subgraph ExtMCP["External MCP Servers"]
Weather[Weather MCP]
Other[Other Services]
end
%% External clients connect to standalone MCP server
CC --> NMCP_Server
IDE --> NMCP_Server
APP --> NMCP_Server
%% Standalone MCP server talks to Nextcloud over HTTP
NMCP_Server --> NMCP_Auth
NMCP_Auth --> NMCP_Client
NMCP_Client -->|HTTP/HTTPS| NC_Apps
%% Context Agent is inside Nextcloud
CA_Agent --> CA_Tools
CA_Tools --> NC_Apps
CA_MCP -->|Exposes to| NC_Assistant
NC_Assistant -->|User requests| CA_Agent
%% Context Agent can consume external MCP servers
CA_Tools -->|Consumes| ExtMCP
%% Context Agent could consume Nextcloud MCP Server
CA_Tools -.->|Could consume| NMCP_Server
classDef external fill:#e1f5ff
classDef standalone fill:#fff4e1
classDef internal fill:#e8f5e9
class CC,IDE,APP external
class NMCP_Server,NMCP_Client,NMCP_Auth standalone
class CA_Agent,CA_MCP,CA_Tools,NC_Apps,NC_Assistant internal
```
## Deployment Models
```mermaid
graph LR
subgraph Deploy1["Nextcloud MCP Server Deployment"]
direction TB
D1[Docker Container]
D2[Cloud VM]
D3[Local Machine]
D4[Kubernetes Pod]
end
subgraph Deploy2["Context Agent Deployment"]
direction TB
NC[Nextcloud Instance<br/>with AppAPI]
ExApp[External App Container<br/>Managed by Nextcloud]
end
Deploy1 -.->|HTTP/HTTPS| NC
ExApp -->|Integrated| NC
classDef deploy fill:#fff4e1
classDef integrated fill:#e8f5e9
class D1,D2,D3,D4 deploy
class NC,ExApp integrated
```
### Nextcloud MCP Server
- **Location**: Runs anywhere with network access to Nextcloud
- **Deployment**: Docker, VM, local machine, Kubernetes
- **Connection**: HTTP/HTTPS to Nextcloud APIs
- **Independence**: Fully standalone service
### Context Agent
- **Location**: Runs inside Nextcloud as External App
- **Deployment**: Managed by Nextcloud AppAPI
- **Connection**: Native nc-py-api integration
- **Integration**: Deep Nextcloud integration
## Authentication Architecture
```mermaid
graph TB
subgraph NMCP_Auth["Nextcloud MCP Server Authentication"]
direction TB
Client1[MCP Client]
subgraph BasicAuth["BasicAuth Mode"]
BA_Shared[Shared NextcloudClient]
BA_Creds[Username + Password]
end
subgraph OAuth["OAuth Mode"]
OAuth_Token[OAuth Token]
OAuth_Verify[Token Verifier]
OAuth_OIDC[OIDC Discovery]
OAuth_Client[Per-Request Client]
end
Client1 -->|Basic Auth| BasicAuth
Client1 -->|Bearer Token| OAuth
BA_Creds --> BA_Shared
OAuth_Token --> OAuth_Verify
OAuth_OIDC --> OAuth_Verify
OAuth_Verify --> OAuth_Client
end
subgraph CA_Auth["Context Agent Authentication"]
direction TB
Client2[MCP Client]
CA_Header[Authorization Header]
CA_OCS[OCS API Validation]
CA_User[User Context]
CA_NC[nc-py-api Client]
Client2 --> CA_Header
CA_Header --> CA_OCS
CA_OCS -->|Extract user_id| CA_User
CA_User -->|nc.set_user| CA_NC
end
classDef auth fill:#fff4e1
classDef user fill:#e1f5ff
class BasicAuth,OAuth auth
class CA_User user
```
## Tool Registration & Loading
```mermaid
sequenceDiagram
participant Startup
participant NMCP as Nextcloud MCP<br/>Server
participant CA as Context Agent
participant Request as Client Request
Note over Startup,NMCP: Nextcloud MCP Server (Static)
Startup->>NMCP: Server starts
NMCP->>NMCP: configure_notes_tools(mcp)
NMCP->>NMCP: configure_calendar_tools(mcp)
NMCP->>NMCP: configure_contacts_tools(mcp)
Note over NMCP: Tools registered once<br/>at startup
Request->>NMCP: Call tool
NMCP->>NMCP: Use pre-registered tool
Note over Startup,CA: Context Agent (Dynamic)
Startup->>CA: Server starts
CA->>CA: Install ToolListMiddleware
Request->>CA: List tools (or 60s elapsed)
CA->>CA: get_tools(nc)
CA->>CA: Import all_tools/*.py
CA->>CA: Call module.get_tools(nc)
CA->>CA: Regenerate tool functions
Note over CA: Tools refreshed every 60s<br/>or on demand
Request->>CA: Call tool
CA->>CA: Regenerate with fresh nc
```
## Tool Definition Patterns
### Nextcloud MCP Server
```python
# Static registration at startup
def configure_notes_tools(mcp: FastMCP):
@mcp.tool()
async def nc_notes_create_note(
title: str,
content: str,
category: str,
ctx: Context
) -> CreateNoteResponse:
"""Create a new note"""
client = get_client(ctx) # Auto-detects auth mode
note_data = await client.notes.create_note(
title=title,
content=content,
category=category
)
return CreateNoteResponse(
id=note_data["id"],
title=note_data["title"],
etag=note_data["etag"]
)
# Resources for structured data access
@mcp.resource("nc://Notes/{note_id}")
async def nc_get_note_resource(note_id: int):
"""Get user note using note id"""
ctx = mcp.get_context()
client = get_client(ctx)
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
```
**Key Features**:
- Native FastMCP `@mcp.tool()` decorator
- Pydantic models for type safety
- MCP Resources support
- Comprehensive error handling with McpError
- Context-based client resolution
### Context Agent
```python
# Dynamic loading at runtime
async def get_tools(nc: Nextcloud):
@tool
@safe_tool
def list_calendars():
"""List all existing calendars by name"""
principal = nc.cal.principal()
calendars = principal.calendars()
return ", ".join([cal.name for cal in calendars])
@tool
@dangerous_tool
def schedule_event(
calendar_name: str,
title: str,
description: str,
start_date: str,
end_date: str,
attendees: list[str] | None,
start_time: str | None,
end_time: str | None
):
"""Create a new event or meeting in a calendar"""
# Parse dates and times
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
# ... event creation logic
principal = nc.cal.principal()
calendar = {cal.name: cal for cal in calendars}[calendar_name]
calendar.add_event(str(c))
return True
return [list_calendars, schedule_event, ...]
def get_category_name():
return "Calendar and Tasks"
def is_available(nc: Nextcloud):
return True # or check capabilities
```
**Key Features**:
- LangChain `@tool` decorator
- `@safe_tool` / `@dangerous_tool` decorators
- Dynamic tool regeneration with fresh context
- Tools returned as list from async function
- Availability checking per module
## Client Architecture
```mermaid
graph TB
subgraph NMCP_Client["Nextcloud MCP Server Clients"]
direction TB
NMCP_Main[NextcloudClient]
NMCP_Base[BaseNextcloudClient]
NMCP_Notes[NotesClient]
NMCP_Cal[CalendarClient]
NMCP_Contacts[ContactsClient]
NMCP_Tables[TablesClient]
NMCP_WebDAV[WebDAVClient]
NMCP_Deck[DeckClient]
NMCP_Main --> NMCP_Notes
NMCP_Main --> NMCP_Cal
NMCP_Main --> NMCP_Contacts
NMCP_Main --> NMCP_Tables
NMCP_Main --> NMCP_WebDAV
NMCP_Main --> NMCP_Deck
NMCP_Notes -.->|extends| NMCP_Base
NMCP_Cal -.->|extends| NMCP_Base
NMCP_Contacts -.->|extends| NMCP_Base
NMCP_Base --> HTTPX["httpx.AsyncClient"]
NMCP_Base --> Retry["@retry_on_429"]
end
subgraph CA_Client["Context Agent Client"]
direction TB
CA_NC["nc-py-api<br/>NextcloudApp"]
CA_NC --> CA_Cal["nc.cal<br/>CalDAV"]
CA_NC --> CA_Talk["nc.talk<br/>Talk API"]
CA_NC --> CA_OCS["nc.ocs<br/>OCS API"]
CA_NC --> CA_Session["nc._session<br/>HTTP Adapter"]
end
HTTPX -->|"HTTP/HTTPS"| NextcloudAPI["Nextcloud APIs"]
CA_Session -->|"HTTP/HTTPS"| NextcloudAPI
classDef custom fill:#fff4e1
classDef native fill:#e8f5e9
class NMCP_Main,NMCP_Base,NMCP_Notes,NMCP_Cal custom
class CA_NC,CA_Cal,CA_Talk,CA_OCS native
```
## Functionality Comparison
### Available Tools & Features
| Feature Category | Nextcloud MCP Server | Context Agent MCP |
|-----------------|---------------------|-------------------|
| **Notes** | ✅ Full CRUD, search, attachments (7 tools) | ❌ Not implemented |
| **Calendar** | ✅ Full CalDAV (events, recurring, attendees) | ✅ Schedule events, list calendars, free/busy, tasks (4 tools) |
| **Contacts** | ✅ Full CardDAV (address books, contacts) | ✅ Find person, current user details (2 tools) |
| **Files** | ✅ Full WebDAV (read, write, directories) | ✅ Get content, folder tree, sharing (3 tools) |
| **Tables** | ✅ Row CRUD operations | ❌ Not implemented |
| **Deck** | ✅ Boards, stacks, cards | ✅ Create board, add card (2 tools) |
| **Talk** | ❌ Not implemented | ✅ List/send messages, create conversation (4 tools) |
| **Mail** | ❌ Not implemented | ✅ Send email, list mailboxes (2 tools) |
| **AI Features** | ❌ Not implemented | ✅ Image gen, audio2text, doc-gen, context_chat (4 tools) |
| **Web Search** | ❌ Not implemented | ✅ DuckDuckGo, YouTube search (2 tools) |
| **Location** | ❌ Not implemented | ✅ OpenStreetMap, HERE transit, weather (3 tools) |
| **OpenProject** | ❌ Not implemented | ✅ Integration (2 tools) |
| **MCP Resources** | ✅ notes://, nc:// URIs | ❌ Not supported |
| **External MCP** | ❌ Pure server only | ✅ Consumes external MCP servers |
| **Sharing** | ✅ Share management API | ❌ Not implemented |
| **Capabilities** | ✅ Server info resource | ❌ Not exposed |
### Tool Count Summary
- **Nextcloud MCP Server**: ~50+ tools and resources
- Deep integration with specific apps
- Full CRUD operations
- MCP Resources for structured data
- **Context Agent**: ~28+ tools
- Broader feature coverage
- Action-oriented (agent tasks)
- Can aggregate external MCP servers
## Tool Safety & Confirmation
### Context Agent Safety Model
```mermaid
graph TD
Request[User Request] --> Agent[LangGraph Agent]
Agent --> Model[LLM generates tool calls]
Model --> Check{Tool type?}
Check -->|"@safe_tool"| Execute[Execute immediately]
Check -->|"@dangerous_tool"| Queue[Queue for confirmation]
Queue --> UserNode[Request user confirmation]
UserNode -->|Approved| Execute
UserNode -->|Denied| Cancel[Cancel with reason]
Execute --> Result[Return result to agent]
Cancel --> Result
Result --> Agent
classDef safe fill:#e8f5e9
classDef danger fill:#ffe8e8
class Execute safe
class Queue,UserNode,Cancel danger
```
**Safe Tools** (read-only):
- `list_calendars`
- `find_person_in_contacts`
- `list_talk_conversations`
- `get_file_content`
- `get_folder_tree`
**Dangerous Tools** (write operations):
- `schedule_event`
- `send_message_to_conversation`
- `create_public_sharing_link`
- `send_email`
### Nextcloud MCP Server Safety
**No built-in safety classification**:
- All tools treated equally
- Relies on MCP client for validation
- OAuth scopes could control permissions
- User must review all actions
## Error Handling
### Nextcloud MCP Server
```python
try:
note_data = await client.notes.create_note(...)
return CreateNoteResponse(...)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(ErrorData(
code=-1,
message="Access denied: insufficient permissions"
))
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="Note with this title already exists"
))
```
**Features**:
- Comprehensive HTTP status code handling
- User-friendly error messages
- Specific error codes
- Guidance on resolution
### Context Agent
```python
def schedule_event(...):
"""Create event"""
# ... implementation
calendar.add_event(str(c))
return True # Simple boolean return
```
**Features**:
- Minimal error handling
- Exceptions propagate to agent
- LangChain handles retries
- Agent interprets failures
## Use Cases
### When to Use Nextcloud MCP Server
```mermaid
graph LR
Root[Nextcloud MCP Server]
Root --> ExtAccess[External Access]
Root --> OAuth[OAuth Security]
Root --> DeepAPI[Deep API Access]
Root --> Deploy[Standalone Deployment]
ExtAccess --> EA1[Claude Code integration]
ExtAccess --> EA2[IDE plugins with MCP]
ExtAccess --> EA3[Custom MCP clients]
ExtAccess --> EA4[Cross-platform tools]
OAuth --> O1[Token-based auth]
OAuth --> O2[OIDC compliance]
OAuth --> O3[Per-user permissions]
OAuth --> O4[Secure external access]
DeepAPI --> DA1[Full CRUD operations]
DeepAPI --> DA2[Notes management]
DeepAPI --> DA3[Calendar CalDAV]
DeepAPI --> DA4[Contacts CardDAV]
DeepAPI --> DA5[File operations]
DeepAPI --> DA6[Table data]
Deploy --> D1[Docker containers]
Deploy --> D2[Cloud VMs]
Deploy --> D3[Kubernetes]
Deploy --> D4[On-premise servers]
classDef rootStyle fill:#4a90e2,stroke:#2e5c8a,color:#fff
classDef categoryStyle fill:#f39c12,stroke:#d68910,color:#fff
classDef itemStyle fill:#e8f5e9,stroke:#81c784
class Root rootStyle
class ExtAccess,OAuth,DeepAPI,Deploy categoryStyle
class EA1,EA2,EA3,EA4,O1,O2,O3,O4,DA1,DA2,DA3,DA4,DA5,DA6,D1,D2,D3,D4 itemStyle
```
**Best for**:
1. External clients accessing Nextcloud (Claude Code, IDEs)
2. OAuth/OIDC authentication requirements
3. Full CRUD on Notes, Calendar, Contacts, Tables
4. WebDAV file system access
5. MCP Resources for structured data
6. Flexible deployment scenarios
7. Building external integrations
### When to Use Context Agent MCP Server
```mermaid
graph LR
Root[Context Agent MCP]
Root --> Assistant[AI Assistant]
Root --> ActionOriented[Action-Oriented]
Root --> MCPAgg[MCP Aggregation]
Root --> Safety[Safety Features]
Assistant --> A1[Nextcloud UI integration]
Assistant --> A2[Task Processing API]
Assistant --> A3[User requests in Assistant]
Assistant --> A4[Human-in-the-loop]
ActionOriented --> AO1[Send emails]
ActionOriented --> AO2[Create calendar events]
ActionOriented --> AO3[Post Talk messages]
ActionOriented --> AO4[Generate images]
ActionOriented --> AO5[Search web]
MCPAgg --> M1[Consume external MCP servers]
MCPAgg --> M2[Weather services]
MCPAgg --> M3[Maps and transit]
MCPAgg --> M4[Custom integrations]
MCPAgg --> M5[Unified tool interface]
Safety --> S1[Read operations auto-execute]
Safety --> S2[Write operations require approval]
Safety --> S3[User confirmation flow]
Safety --> S4[Agent safety]
classDef rootStyle fill:#9b59b6,stroke:#6c3483,color:#fff
classDef categoryStyle fill:#e74c3c,stroke:#c0392b,color:#fff
classDef itemStyle fill:#fff4e1,stroke:#f39c12
class Root rootStyle
class Assistant,ActionOriented,MCPAgg,Safety categoryStyle
class A1,A2,A3,A4,AO1,AO2,AO3,AO4,AO5,M1,M2,M3,M4,M5,S1,S2,S3,S4 itemStyle
```
**Best for**:
1. AI-driven actions inside Nextcloud UI
2. Assistant app integration
3. Safe/dangerous tool distinction
4. Talk, Mail, Deck operations
5. AI features (image gen, audio2text)
6. Web search and maps
7. Aggregating external MCP servers
8. Agent acting on behalf of users
## Complementary Architecture
The two MCP servers can work together in complementary ways:
```mermaid
graph TB
User[User] -->|Requests AI assistance| Assistant[Nextcloud Assistant App]
Assistant --> ContextAgent[Context Agent]
subgraph ContextAgent["Context Agent (Inside Nextcloud)"]
direction TB
Agent[LangGraph Agent]
MCPServer[MCP Server /mcp]
ToolLoader[Tool Loader]
Agent --> ToolLoader
ToolLoader --> InternalTools[Internal Tools<br/>Talk, Mail, Calendar]
end
subgraph ExternalMCP["External MCP Ecosystem"]
NextcloudMCP[Nextcloud MCP Server<br/>This Project]
WeatherMCP[Weather MCP]
CustomMCP[Custom MCP Services]
end
ToolLoader -->|Consumes| NextcloudMCP
ToolLoader -->|Consumes| WeatherMCP
ToolLoader -->|Consumes| CustomMCP
subgraph ExternalClients["External Clients"]
Claude[Claude Code]
IDE[IDEs with MCP]
end
Claude -->|Direct access| NextcloudMCP
IDE -->|Direct access| NextcloudMCP
NextcloudMCP -->|OAuth/HTTP| NextcloudApps[Nextcloud Apps<br/>Notes, Calendar, Files]
InternalTools -->|nc-py-api| NextcloudApps
classDef internal fill:#e8f5e9
classDef external fill:#e1f5ff
classDef mcp fill:#fff4e1
class Assistant,Agent,MCPServer,ToolLoader,InternalTools,NextcloudApps internal
class Claude,IDE external
class NextcloudMCP,WeatherMCP,CustomMCP mcp
```
### Example Workflows
**Workflow 1: External Client → Nextcloud MCP Server**
```
Claude Code → Nextcloud MCP Server → Nextcloud Notes API
```
- User asks Claude Code to search notes
- Claude Code calls `nc_notes_search_notes` tool
- Returns results directly to user
**Workflow 2: Assistant → Context Agent → Internal Tools**
```
User → Assistant → Context Agent → Send Email Tool
```
- User asks Assistant to send an email
- Context Agent identifies "send_email" as dangerous
- Requests user confirmation
- Sends email via nc-py-api
**Workflow 3: Assistant → Context Agent → External MCP**
```
User → Assistant → Context Agent → Nextcloud MCP Server → Notes
```
- User asks Assistant about notes
- Context Agent consumes Nextcloud MCP Server as external MCP
- Gets notes data via MCP protocol
- Returns to user via Assistant
## Technical Comparison Matrix
| Aspect | Nextcloud MCP Server | Context Agent MCP |
|--------|---------------------|-------------------|
| **Framework** | FastMCP (native) | FastMCP + LangChain |
| **Tool Decorator** | `@mcp.tool()` | `@tool` from LangChain |
| **Tool Loading** | Static (startup) | Dynamic (runtime) |
| **Tool Refresh** | No (restart required) | Every 60 seconds |
| **Resources** | Yes (`@mcp.resource()`) | No |
| **Transports** | SSE, HTTP, Streamable-HTTP | Stateless HTTP only |
| **MCP Mode** | Server only | Server + Client (hybrid) |
| **Client Type** | httpx (custom HTTP) | nc-py-api (native) |
| **Deployment** | Standalone external | Inside Nextcloud (ExApp) |
| **Auth** | BasicAuth or OAuth/OIDC | Session-based (ExApp) |
| **User Context** | Shared or per-token | Per-request `nc.set_user()` |
| **Error Handling** | McpError with codes | Basic exceptions |
| **Type Safety** | Pydantic models | Python types |
| **Safety Model** | No built-in | Safe/Dangerous classification |
| **Dependencies** | FastMCP, httpx, Pydantic | nc-py-api, LangChain, LangGraph |
| **Integration** | HTTP APIs | AppAPI + Task Processing |
| **External MCP** | No | Yes (consumes) |
## Summary
Both MCP servers serve important but different roles in the Nextcloud ecosystem:
### Nextcloud MCP Server (This Project)
- **Purpose**: Expose Nextcloud to external MCP clients
- **Strength**: Deep CRUD operations, OAuth security, standalone deployment
- **Audience**: External developers, Claude Code users, integration builders
### Context Agent MCP Server
- **Purpose**: Bring AI agent capabilities to Nextcloud users
- **Strength**: Action-oriented, safe/dangerous tools, MCP aggregation
- **Audience**: Nextcloud users via Assistant app, AI-driven workflows
**Key Insight**: These are complementary, not competing. Context Agent could even consume Nextcloud MCP Server as one of its external MCP sources, creating a unified ecosystem where:
- External clients access Nextcloud via Nextcloud MCP Server
- Internal users leverage Context Agent for AI assistance
- Context Agent aggregates both internal tools and external MCP servers (including Nextcloud MCP Server)
+253
View File
@@ -0,0 +1,253 @@
# Configuration
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
## Quick Start
Create a `.env` file based on `env.sample`:
```bash
cp env.sample .env
# Edit .env with your Nextcloud details
```
Then choose your authentication mode:
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
- [Basic Authentication Configuration](#basic-authentication-legacy)
---
## OAuth2/OIDC Configuration
OAuth2/OIDC is the recommended authentication mode for production deployments.
### Minimal Configuration (Auto-registration)
```dotenv
# .env file for OAuth with auto-registration
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
```
This minimal configuration uses dynamic client registration to automatically register an OAuth client at startup.
### Full Configuration (Pre-configured Client)
```dotenv
# .env file for OAuth with pre-configured client
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# OAuth Client Credentials (optional - auto-registers if not provided)
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# OAuth Storage and Callback Settings (optional)
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
### Prerequisites
Before using OAuth configuration:
1. **Install required Nextcloud apps** (both are required):
- **`oidc`** - OIDC Identity Provider (Apps → Security)
- **`user_oidc`** - OpenID Connect user backend (Apps → Security)
2. **Configure the apps**:
- Enable dynamic client registration (if using auto-registration) - Settings → OIDC
- Enable Bearer token validation: `php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean`
3. **Apply Bearer token patch** - The `user_oidc` app requires a patch for non-OCS endpoints - See [Upstream Status](oauth-upstream-status.md) for details
See the [OAuth Setup Guide](oauth-setup.md) for detailed step-by-step instructions, or [OAuth Quick Start](quickstart-oauth.md) for a 5-minute setup.
---
## Basic Authentication (Legacy)
Basic Authentication is maintained for backward compatibility. It uses username and password credentials.
> [!WARNING]
> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. Use OAuth for production deployments.
### Configuration
```dotenv
# .env file for BasicAuth mode
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_USERNAME=your_nextcloud_username
NEXTCLOUD_PASSWORD=your_app_password_or_password
```
### Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXTCLOUD_HOST` | ✅ Yes | Full URL of your Nextcloud instance |
| `NEXTCLOUD_USERNAME` | ✅ Yes | Your Nextcloud username |
| `NEXTCLOUD_PASSWORD` | ✅ Yes | **Recommended:** Use a dedicated [Nextcloud App Password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices). Generate one in Nextcloud Security settings. Alternatively, use your login password (less secure). |
---
## Loading Environment Variables
After creating your `.env` file, load the environment variables:
### On Linux/macOS
```bash
# Load all variables from .env
export $(grep -v '^#' .env | xargs)
```
### On Windows (PowerShell)
```powershell
# Load variables from .env
Get-Content .env | ForEach-Object {
if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
}
}
```
### Via Docker
```bash
# Docker automatically loads .env when using --env-file
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
---
## CLI Configuration
Some configuration options can also be provided via CLI arguments. CLI arguments take precedence over environment variables.
### OAuth-related CLI Options
```bash
uv run nextcloud-mcp-server --help
Options:
--oauth / --no-oauth Force OAuth mode (if enabled) or
BasicAuth mode (if disabled). By default,
auto-detected based on environment
variables.
--oauth-client-id TEXT OAuth client ID (can also use
NEXTCLOUD_OIDC_CLIENT_ID env var)
--oauth-client-secret TEXT OAuth client secret (can also use
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
--oauth-storage-path TEXT Path to store OAuth client credentials
(can also use
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
[default: .nextcloud_oauth_client.json]
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
also use NEXTCLOUD_MCP_SERVER_URL env
var) [default: http://localhost:8000]
```
### Server Options
```bash
Options:
-h, --host TEXT Server host [default: 127.0.0.1]
-p, --port INTEGER Server port [default: 8000]
-w, --workers INTEGER Number of worker processes
-r, --reload Enable auto-reload
-l, --log-level [critical|error|warning|info|debug|trace]
Logging level [default: info]
-t, --transport [sse|streamable-http|http]
MCP transport protocol [default: sse]
```
### App Selection
```bash
Options:
-e, --enable-app [notes|tables|webdav|calendar|contacts|deck]
Enable specific Nextcloud app APIs. Can
be specified multiple times. If not
specified, all apps are enabled.
```
### Example CLI Usage
```bash
# OAuth mode with custom client and port
uv run nextcloud-mcp-server --oauth \
--oauth-client-id abc123 \
--oauth-client-secret xyz789 \
--port 8080
# BasicAuth mode with specific apps only
uv run nextcloud-mcp-server --no-oauth \
--enable-app notes \
--enable-app calendar
```
---
## Configuration Best Practices
### For Development
- Use BasicAuth for quick setup and testing
- Or use OAuth with auto-registration (dynamic client registration)
- Store `.env` file in your project directory
- Add `.env` to `.gitignore`
### For Production
- **Always use OAuth2/OIDC** with pre-configured clients
- Store OAuth client credentials securely
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
- Never commit credentials to version control
- Set appropriate file permissions on credential storage:
```bash
chmod 600 .nextcloud_oauth_client.json
```
### For Docker
- Mount OAuth client storage as a volume for persistence:
```bash
docker run -v $(pwd)/.oauth:/app/.oauth --env-file .env \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
- Use Docker secrets for sensitive values in production
---
## See Also
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs
- [Authentication](authentication.md) - Authentication modes comparison
- [Running the Server](running.md) - Starting the server with different configurations
- [Troubleshooting](troubleshooting.md) - Common configuration issues
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
+12
View File
@@ -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 |
+189
View File
@@ -0,0 +1,189 @@
# Cookbook App
### Cookbook Tools
| Tool | Description |
|------|-------------|
| `nc_cookbook_import_recipe` | Import a recipe from a URL using schema.org metadata |
| `nc_cookbook_create_recipe` | Create a new recipe with all schema.org fields |
| `nc_cookbook_get_recipe` | Get a specific recipe by ID |
| `nc_cookbook_update_recipe` | Update an existing recipe |
| `nc_cookbook_delete_recipe` | Delete a recipe permanently |
| `nc_cookbook_list_recipes` | Get all recipes in the database |
| `nc_cookbook_search_recipes` | Search for recipes by keywords, tags, and categories |
| `nc_cookbook_list_categories` | Get all known recipe categories |
| `nc_cookbook_get_recipes_in_category` | Get all recipes in a specific category |
| `nc_cookbook_list_keywords` | Get all known recipe keywords/tags |
| `nc_cookbook_get_recipes_with_keywords` | Get all recipes that have specific keywords |
| `nc_cookbook_set_config` | Set Cookbook app configuration |
| `nc_cookbook_reindex` | Trigger a rescan of all recipes into the search database |
### Cookbook Resources
| Resource | Description |
|----------|-------------|
| `cookbook://version` | Get Cookbook app and API version information |
| `cookbook://config` | Get Cookbook app configuration |
| `nc://Cookbook/{recipe_id}` | Get a specific recipe by ID |
## Recipe Management
The server provides complete Nextcloud Cookbook integration, enabling you to manage your recipe collection:
- **Import recipes from websites** using schema.org metadata
- Full CRUD operations for recipes
- Search and organize with categories and keywords
- Support for structured recipe data (ingredients, instructions, nutrition, etc.)
- Configure app settings and trigger reindexing
### Schema.org Recipe Format
The Cookbook app uses the [schema.org/Recipe](https://schema.org/Recipe) specification for structured recipe data. This standard format includes:
- **Basic info**: Name, description, image, URL
- **Timing**: Preparation time, cooking time, total time (ISO8601 format like `PT30M`)
- **Ingredients**: List of ingredients with quantities
- **Instructions**: Step-by-step cooking instructions
- **Metadata**: Category, keywords/tags, yield (servings)
- **Nutrition**: Optional nutrition information
### Usage Examples
#### Import Recipe from URL
Many recipe websites include schema.org metadata. The import tool automatically extracts this data:
```python
# Import from a recipe website
await nc_cookbook_import_recipe(
url="https://www.example.com/recipes/chocolate-cake"
)
# Returns: Recipe object with all extracted data
```
#### Create Recipe Manually
```python
# Create a new recipe from scratch
await nc_cookbook_create_recipe(
name="Homemade Pizza",
description="Classic homemade pizza with fresh ingredients",
ingredients=[
"500g pizza dough",
"200g tomato sauce",
"300g mozzarella cheese",
"Fresh basil leaves",
"Olive oil"
],
instructions=[
"Preheat oven to 250°C (480°F)",
"Roll out the pizza dough",
"Spread tomato sauce evenly",
"Add mozzarella cheese",
"Bake for 10-12 minutes",
"Top with fresh basil and olive oil"
],
category="Main Course",
keywords="italian,vegetarian,quick",
prep_time="PT20M", # 20 minutes
cook_time="PT12M", # 12 minutes
total_time="PT32M", # 32 minutes
recipe_yield=4 # 4 servings
)
```
#### Update Recipe
```python
# Update recipe details (only specified fields are changed)
await nc_cookbook_update_recipe(
recipe_id=123,
description="Updated: Classic homemade pizza - now with video tutorial!",
url="https://example.com/videos/pizza-tutorial",
keywords="italian,vegetarian,quick,video"
)
```
#### Search and Filter
```python
# Search recipes by keyword
results = await nc_cookbook_search_recipes(query="chocolate")
# List all categories
categories = await nc_cookbook_list_categories()
# Returns: [{"name": "Desserts", "recipe_count": 15}, ...]
# Get recipes in a category
desserts = await nc_cookbook_get_recipes_in_category(category="Desserts")
# List all keywords/tags
keywords = await nc_cookbook_list_keywords()
# Returns: [{"name": "chocolate", "recipe_count": 8}, ...]
# Get recipes with specific tags
quick_meals = await nc_cookbook_get_recipes_with_keywords(keywords=["quick", "30min"])
```
#### Manage Configuration
```python
# Configure the Cookbook app
await nc_cookbook_set_config(
folder="Recipes", # Folder path in user's files
update_interval=15, # Auto-rescan every 15 minutes
print_image=True # Print images with recipes
)
# Trigger manual reindex after file changes
await nc_cookbook_reindex()
```
### Time Format (ISO8601 Duration)
Recipe times use ISO8601 duration format:
| Duration | Format | Example |
|----------|--------|---------|
| 15 minutes | `PT15M` | Prep time |
| 1 hour | `PT1H` | Baking time |
| 1 hour 30 minutes | `PT1H30M` | Total time |
| 45 seconds | `PT45S` | Mixing time |
| 2 hours 15 minutes | `PT2H15M` | Slow cooking |
### Tips for Recipe Import
**Best practices for importing recipes from URLs:**
1. **Look for schema.org support**: Most modern recipe sites include schema.org metadata
2. **Check import quality**: Review imported recipes for completeness
3. **Handle duplicates**: The API prevents duplicate imports by recipe name
4. **Edit after import**: Update imported recipes with personal notes or adjustments
**Common recipe websites with good schema.org support:**
- AllRecipes
- Food Network
- BBC Good Food
- Serious Eats
- Bon Appétit
- Many food blogs using recipe plugins
### Organizing Your Recipes
**Categories**: Organize recipes by type (Appetizers, Main Course, Desserts, etc.)
- Use `nc_cookbook_list_categories` to see all categories
- Filter by category with `nc_cookbook_get_recipes_in_category`
**Keywords/Tags**: Tag recipes with searchable terms (vegetarian, quick, spicy, etc.)
- Use `nc_cookbook_list_keywords` to see all tags
- Filter by tags with `nc_cookbook_get_recipes_with_keywords`
- Search across all fields with `nc_cookbook_search_recipes`
**Reindexing**: The Cookbook app maintains a search index
- Automatically scans at configured intervals
- Manually trigger with `nc_cookbook_reindex` after bulk changes
- Required after modifying recipe files directly in WebDAV
## API Reference
For detailed API documentation, see the [Nextcloud Cookbook OpenAPI specification](https://github.com/nextcloud/cookbook/tree/master/docs/dev/api/0.1.2).
+108
View File
@@ -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)
```
+215
View File
@@ -0,0 +1,215 @@
# Installation
This guide covers installing the Nextcloud MCP server on your system.
## Prerequisites
- **Python 3.11+** - Check with `python3 --version`
- **Access to a Nextcloud instance** - Self-hosted or cloud-hosted
- **Administrator access** (for OAuth setup) - Required to install OIDC app
## Installation Methods
Choose one of the following installation methods:
- [From Source (Recommended)](#from-source-recommended)
- [Using Docker](#using-docker)
---
## From Source (Recommended)
Install from the GitHub repository using uv or pip.
### Prerequisites
Install [uv](https://github.com/astral-sh/uv) (recommended) or ensure pip is available:
```bash
# Install uv (recommended)
# On macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# On Windows
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
### Clone the Repository
```bash
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
```
### Install Dependencies
#### Using uv (Recommended)
```bash
# Install dependencies
uv sync
# Install development dependencies (optional)
uv sync --group dev
```
#### Using pip
```bash
# Create virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install in development mode
pip install -e .
# Install development dependencies (optional)
pip install -e ".[dev]"
```
### Verify Installation
```bash
# With uv
uv run nextcloud-mcp-server --help
# With pip/venv
nextcloud-mcp-server --help
```
---
## Using Docker
A pre-built Docker image is available for easy deployment.
### Pull the Image
```bash
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Run the Container
```bash
# Prepare your .env file first (see Configuration guide)
# Run with environment file
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Docker Compose
Create a `docker-compose.yml`:
```yaml
version: '3.8'
services:
mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
volumes:
# For persistent OAuth client storage
- ./oauth-storage:/app/.oauth
restart: unless-stopped
```
Start the service:
```bash
docker-compose up -d
```
---
## Next Steps
After installation:
1. **Configure the server** - See [Configuration Guide](configuration.md)
2. **Set up authentication** - See [OAuth Setup Guide](oauth-setup.md) or [Authentication](authentication.md)
3. **Run the server** - See [Running the Server](running.md)
## Updating
### Update from Source
```bash
cd nextcloud-mcp-server
git pull origin master
# Using uv
uv sync
# Or using pip
pip install -e .
```
### Update Docker Image
```bash
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# If using docker-compose
docker-compose up -d # Restart with new image
# If using docker run
# Stop the old container and start a new one with the updated image
```
## Troubleshooting Installation
### Issue: "Python version too old"
**Cause:** Python 3.11+ is required.
**Solution:**
```bash
# Check your Python version
python3 --version
# Install Python 3.11+ from:
# - https://www.python.org/downloads/
# - Or use your system package manager (apt, brew, etc.)
```
### Issue: "Command not found: nextcloud-mcp-server"
**Cause:** The package is not in your PATH.
**Solution:**
```bash
# Ensure your virtual environment is activated
source venv/bin/activate
# Or use uv run
uv run nextcloud-mcp-server --help
# Or use python -m
python -m nextcloud_mcp_server.app --help
```
### Issue: Docker permission denied
**Cause:** Docker requires elevated permissions.
**Solution:**
```bash
# Add your user to the docker group (Linux)
sudo usermod -aG docker $USER
# Log out and back in
# Or use sudo
sudo docker run ...
```
## See Also
- [Configuration Guide](configuration.md) - Environment variables and settings
- [OAuth Setup Guide](oauth-setup.md) - OAuth authentication setup
- [Running the Server](running.md) - Starting and managing the server
+19
View File
@@ -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.
+319
View File
@@ -0,0 +1,319 @@
# OAuth Architecture
This document explains how OAuth2/OIDC authentication works in the Nextcloud MCP Server implementation.
## Overview
The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources. It relies on Nextcloud's OIDC Identity Provider for user authentication and token validation.
## Architecture Diagram
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ MCP Client │ │ MCP Server │ │ Nextcloud │
│ (Claude, │ │ (Resource │ │ Instance │
│ etc.) │ │ Server) │ │ │
│ │ │ │ │ │
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ │ │
│ 1. Connect to MCP │ │
├─────────────────────────────────>│ │
│ │ │
│ 2. Return auth settings │ │
│ (issuer_url, scopes) │ │
│<─────────────────────────────────┤ │
│ │ │
│ │ │
│ 3. Start OAuth flow (with PKCE) │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ /apps/oidc/authorize │
│ │ │
│ 4. User authenticates in browser│ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ 5. Authorization code (redirect)│ │
│<─────────────────────────────────┤ │
│ │ │
│ 6. Exchange code for token │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ /apps/oidc/token │
│ │ │
│ 7. Access token │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ │ │
│ 8. API request with Bearer token│ │
├─────────────────────────────────>│ │
│ Authorization: Bearer xxx │ │
│ │ │
│ │ 9. Validate token via userinfo │
│ ├────────────────────────────────────>│
│ │ /apps/oidc/userinfo │
│ │ │
│ │ 10. User info (token valid) │
│ │<────────────────────────────────────┤
│ │ │
│ │ 11. Nextcloud API request │
│ ├────────────────────────────────────>│
│ │ Authorization: Bearer xxx │
│ │ (Notes, Calendar, etc.) │
│ │ │
│ │ 12. API response │
│ │<────────────────────────────────────┤
│ │ │
│ 13. MCP tool response │ │
│<─────────────────────────────────┤ │
│ │ │
```
## Components
### 1. MCP Client
- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients)
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
- Stores and sends access token with each request
- **Example**: Claude Desktop, Claude Code
### 2. MCP Server (Resource Server)
- **Role**: OAuth 2.0 Resource Server
- **Location**: This Nextcloud MCP Server implementation
- **Responsibilities**:
- Validates Bearer tokens by calling Nextcloud's userinfo endpoint
- Caches validated tokens (default: 1 hour TTL)
- Creates authenticated Nextcloud client instances per-user
- Enforces PKCE requirements (S256 code challenge method)
- Exposes Nextcloud functionality via MCP tools
**Key Files**:
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode detection and configuration
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation logic
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
### 3. Nextcloud OIDC Apps
#### a) `oidc` - OIDC Identity Provider
- **Role**: OAuth 2.0 Authorization Server
- **Location**: Nextcloud app (`apps/oidc`)
- **Endpoints**:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/apps/oidc/authorize` - Authorization endpoint
- `/apps/oidc/token` - Token endpoint
- `/apps/oidc/userinfo` - User info endpoint (token validation)
- `/apps/oidc/jwks` - JSON Web Key Set
- `/apps/oidc/register` - Dynamic client registration
**Configuration**:
```bash
# Enable dynamic client registration (optional)
# Settings → OIDC → "Allow dynamic client registration"
```
#### b) `user_oidc` - OpenID Connect User Backend
- **Role**: Bearer token validation middleware
- **Location**: Nextcloud app (`apps/user_oidc`)
- **Responsibilities**:
- Validates Bearer tokens for Nextcloud API requests
- Creates user sessions from valid Bearer tokens
- Integrates with Nextcloud's authentication system
**Configuration**:
```bash
# Enable Bearer token validation (required)
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
> [!IMPORTANT]
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints. See [Upstream Status](oauth-upstream-status.md) for details.
### 4. Nextcloud Instance
- **Role**: Resource Owner / API Provider
- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc.
## Authentication Flow
### Phase 1: OAuth Authorization (Steps 1-7)
1. **Client Connects**: MCP client connects to MCP server
2. **Auth Settings**: MCP server returns OAuth settings:
```json
{
"issuer_url": "https://nextcloud.example.com",
"resource_server_url": "http://localhost:8000",
"required_scopes": ["openid", "profile"]
}
```
3. **OAuth Flow**: Client initiates OAuth flow with PKCE
- Generates `code_verifier` (random string)
- Calculates `code_challenge` = SHA256(code_verifier)
- Redirects user to `/apps/oidc/authorize` with `code_challenge`
4. **User Authentication**: User logs in to Nextcloud via browser
5. **Authorization Code**: Nextcloud redirects back with authorization code
6. **Token Exchange**: Client exchanges code for access token
- Sends `code` + `code_verifier` to `/apps/oidc/token`
- OIDC app validates PKCE challenge
7. **Access Token**: Client receives access token (JWT or opaque)
### Phase 2: API Access (Steps 8-13)
8. **API Request**: Client sends MCP request with Bearer token
9. **Token Validation**: MCP server validates token:
- Checks cache (1-hour TTL by default)
- If not cached, calls `/apps/oidc/userinfo` with Bearer token
- Extracts username from `sub` or `preferred_username` claim
10. **User Info**: Nextcloud returns user info if token is valid
11. **Nextcloud API Call**: MCP server calls Nextcloud API on behalf of user
- Creates `NextcloudClient` instance with Bearer token
- User-specific permissions apply
12. **API Response**: Nextcloud returns data
13. **MCP Response**: MCP server returns formatted response to client
## Token Validation
The MCP server validates tokens using the **userinfo endpoint approach**:
### Why Userinfo (vs JWT Validation)?
**Advantages**:
- Works with both JWT and opaque tokens
- No need to manage JWKS rotation
- Always up-to-date (respects token revocation)
- Simpler implementation
**Caching Strategy**:
- Validated tokens cached for 1 hour (configurable)
- Cache keyed by token string
- Expired tokens re-validated automatically
**Implementation**: See [`NextcloudTokenVerifier`](../nextcloud_mcp_server/auth/token_verifier.py)
## PKCE Requirement
The MCP server **requires** PKCE with S256 code challenge method:
1. Server validates OIDC discovery advertises PKCE support
2. Checks for `code_challenge_methods_supported` field
3. Verifies `S256` is included in supported methods
4. Logs error if PKCE not properly advertised
**Why PKCE?**:
- Required by MCP specification
- Protects against authorization code interception
- Essential for public clients (desktop apps, CLI tools)
**Implementation**: See [`validate_pkce_support()`](../nextcloud_mcp_server/app.py#L31-L93)
## Client Registration
The MCP server supports two client registration modes:
### Automatic Registration (Dynamic Client Registration)
```bash
# No client credentials needed
NEXTCLOUD_HOST=https://nextcloud.example.com
```
**How it works**:
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
2. Calls `/apps/oidc/register` to register a client on first startup
3. Saves credentials to `.nextcloud_oauth_client.json`
4. Reuses these credentials on subsequent startups
5. Re-registers only if credentials are missing or expired
**Best for**: Development, testing, quick deployments
### Pre-configured Client
```bash
# Manual client registration via CLI
php occ oidc:create --name="MCP Server" --type=confidential --redirect-uri="http://localhost:8000/oauth/callback"
# Configure MCP server
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_OIDC_CLIENT_ID=abc123
NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789
```
**Best for**: Production, long-running deployments
## Per-User Client Instances
Each authenticated user gets their own `NextcloudClient` instance:
```python
# From MCP context (contains validated token)
client = get_client_from_context(ctx)
# Creates NextcloudClient with:
# - username: from token's 'sub' or 'preferred_username' claim
# - auth: BearerAuth(token)
```
**Benefits**:
- User-specific permissions
- Audit trail (actions appear from correct user)
- No shared credentials
- Multi-user support
**Implementation**: See [`get_client_from_context()`](../nextcloud_mcp_server/auth/context_helper.py)
## Security Considerations
### Token Storage
- MCP client stores access token
- MCP server does NOT store tokens (validates per-request)
- Token validation results cached in-memory only
### PKCE Protection
- Server validates PKCE is advertised
- Client MUST use PKCE with S256
- Protects against authorization code interception
### Scopes
- Required scopes: `openid`, `profile`
- Additional scopes inferred from userinfo response
### Token Validation
- Every MCP request validates Bearer token
- Cached for performance (1-hour default)
- Calls userinfo endpoint for validation
## Configuration
See [Configuration Guide](configuration.md) for all OAuth environment variables:
| Variable | Purpose |
|----------|---------|
| `NEXTCLOUD_HOST` | Nextcloud instance URL |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
## Testing
The integration test suite includes comprehensive OAuth testing:
- **Automated tests** (Playwright): [`tests/client/test_oauth_playwright.py`](../tests/client/test_oauth_playwright.py)
- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py)
Run OAuth tests:
```bash
# Start OAuth-enabled MCP server
docker-compose up --build -d mcp-oauth
# Run automated tests
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
```
## See Also
- [OAuth Setup Guide](oauth-setup.md) - Configuration steps
- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly
- [Upstream Status](oauth-upstream-status.md) - Required upstream patches
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Authorization Framework
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
+545
View File
@@ -0,0 +1,545 @@
# OAuth Setup Guide
This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server in production.
> **Quick Start?** If you want a 5-minute setup for development, see [OAuth Quick Start](quickstart-oauth.md).
## Table of Contents
- [Prerequisites](#prerequisites)
- [Architecture Overview](#architecture-overview)
- [Step 1: Install Nextcloud Apps](#step-1-install-nextcloud-apps)
- [Step 2: Configure OIDC Apps](#step-2-configure-oidc-apps)
- [Step 3: Choose Deployment Mode](#step-3-choose-deployment-mode)
- [Step 4: Configure MCP Server](#step-4-configure-mcp-server)
- [Step 5: Start and Verify](#step-5-start-and-verify)
- [Testing Authentication](#testing-authentication)
- [Production Recommendations](#production-recommendations)
## Prerequisites
Before beginning, ensure you have:
- **Nextcloud instance** with administrator access
- **Nextcloud version** 28 or later
- **SSH/CLI access** to Nextcloud server (for `occ` commands)
- **Python 3.11+** installed on MCP server host
- **MCP server installed** (see [Installation Guide](installation.md))
## Architecture Overview
The OAuth implementation uses the following components:
```
MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs)
OAuth Flow Bearer Token Auth
```
**Key Roles**:
- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools)
- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens)
- **Nextcloud `user_oidc` app**: Token validation middleware
For detailed architecture, see [OAuth Architecture](oauth-architecture.md).
## Step 1: Install Nextcloud Apps
OAuth authentication requires **two Nextcloud apps** to work together.
### Required Apps
#### 1. `oidc` - OIDC Identity Provider
**Purpose**: Makes Nextcloud an OAuth2/OIDC authorization server
**Installation**:
1. Open Nextcloud as administrator
2. Navigate to **Apps****Security**
3. Find **"OIDC"** (full name: "OIDC Identity Provider")
4. Click **Enable** or **Download and enable**
**Provides**:
- OAuth2 authorization endpoint
- Token endpoint
- User info endpoint
- JWKS endpoint
- Dynamic client registration endpoint (optional)
#### 2. `user_oidc` - OpenID Connect User Backend
**Purpose**: Authenticates users and validates Bearer tokens
**Installation**:
1. In **Apps****Security**
2. Find **"OpenID Connect user backend"** (app ID: `user_oidc`)
3. Click **Enable** or **Download and enable**
**Provides**:
- Bearer token validation against OIDC provider
- User authentication via OIDC
- Session management for authenticated users
> [!IMPORTANT]
> **Upstream Patch Required**: The `user_oidc` app needs a patch for Bearer token support with app-specific APIs (Notes, Calendar, etc.). The patch is pending upstream review.
>
> **Status**: See [Upstream Status](oauth-upstream-status.md) for current PR status and workarounds.
>
> **Impact**: OCS APIs work without patch, but app-specific APIs require the patch.
### Verify Installation
```bash
# Check both apps are installed and enabled
php occ app:list | grep -E "oidc|user_oidc"
# Expected output:
# - oidc: enabled
# - user_oidc: enabled
```
## Step 2: Configure OIDC Apps
### Configure `oidc` App (Identity Provider)
#### Option A: Dynamic Client Registration (Development)
**Best for**: Development, testing, auto-registration
1. Navigate to **Settings****OIDC** (Administration settings)
2. Enable **"Allow dynamic client registration"**
3. (Optional) Configure client expiration:
```bash
# Default: 3600 seconds (1 hour)
php occ config:app:set oidc expire_time --value "86400" # 24 hours
```
#### Option B: Pre-configured Clients (Production)
**Best for**: Production, long-running deployments
Skip the dynamic registration setting. You'll manually register clients via CLI in Step 3.
### Configure `user_oidc` App (Token Validation)
**Required**: Enable Bearer token validation:
```bash
# SSH into Nextcloud server
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
This tells `user_oidc` to validate Bearer tokens against Nextcloud's OIDC Identity Provider.
### Verify OIDC Discovery
Test that OIDC discovery endpoint is accessible:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq
```
Expected response:
```json
{
"issuer": "https://your.nextcloud.instance.com",
"authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize",
"token_endpoint": "https://your.nextcloud.instance.com/apps/oidc/token",
"userinfo_endpoint": "https://your.nextcloud.instance.com/apps/oidc/userinfo",
"jwks_uri": "https://your.nextcloud.instance.com/apps/oidc/jwks",
"registration_endpoint": "https://your.nextcloud.instance.com/apps/oidc/register",
...
}
```
### PKCE Support
The MCP server **requires PKCE** (Proof Key for Code Exchange) with S256 code challenge method.
**Validation**: The MCP server automatically validates PKCE support at startup by checking the discovery response for `code_challenge_methods_supported`.
**Note**: If PKCE is not advertised in discovery metadata, the server logs a warning but continues (PKCE still works, it's just not advertised). See [Upstream Status](oauth-upstream-status.md) for tracking.
## Step 3: Choose Deployment Mode
You have two options for managing OAuth clients:
### Mode A: Automatic Registration (Dynamic Client Registration)
**Best for**: Development, testing, quick deployments
**How it works**:
- MCP server automatically registers an OAuth client on first startup
- Uses Nextcloud's dynamic client registration endpoint
- Saves credentials to `.nextcloud_oauth_client.json`
- Reuses stored credentials on subsequent restarts
- Re-registers automatically if credentials expire
**Pros**:
- Zero configuration required
- Quick setup
- Automatic credential management
**Cons**:
- Clients expire (default: 1 hour, configurable)
- Must have dynamic client registration enabled on Nextcloud
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
---
### Mode B: Pre-configured Client (Production)
**Best for**: Production, long-running deployments, stable environments
**How it works**:
- You manually register an OAuth client via Nextcloud CLI
- Provide client credentials to MCP server via environment variables
- Credentials don't expire
**Pros**:
- Credentials don't expire
- Stable for production
- More control over client configuration
- Better for audit trails
**Cons**:
- Requires manual setup
- Needs SSH/CLI access to Nextcloud server
**Setup**: Register a client via CLI:
```bash
# SSH into Nextcloud server
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Example output:
# Client ID: abc123xyz789
# Client Secret: secret456def012
# Save these credentials for Step 4
```
**Important**: Adjust `--redirect-uri` to match your MCP server URL:
- Local: `http://localhost:8000/oauth/callback`
- Remote: `http://your-server:8000/oauth/callback`
- Custom port: `http://your-server:PORT/oauth/callback`
The redirect URI **must** be:
```
{NEXTCLOUD_MCP_SERVER_URL}/oauth/callback
```
## Step 4: Configure MCP Server
Create or update your `.env` file with OAuth configuration.
### For Mode A (Automatic Registration)
```bash
# Copy sample if needed
cp env.sample .env
# Edit .env
cat > .env << 'EOF'
# Nextcloud Instance
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Leave EMPTY for OAuth mode (do not set USERNAME/PASSWORD)
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: MCP server URL (for OAuth callbacks)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Optional: Client storage path
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
EOF
```
### For Mode B (Pre-configured Client)
```bash
# Copy sample if needed
cp env.sample .env
# Edit .env
cat > .env << 'EOF'
# Nextcloud Instance
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# OAuth Client Credentials (from Step 3)
NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789
NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012
# MCP server URL (must match redirect URI)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Leave EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
EOF
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of Nextcloud instance |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
See [Configuration Guide](configuration.md) for all options.
## Step 5: Start and Verify
### Load Environment Variables
```bash
# Load from .env file
export $(grep -v '^#' .env | xargs)
# Verify key variables are set
echo "NEXTCLOUD_HOST: $NEXTCLOUD_HOST"
echo "NEXTCLOUD_MCP_SERVER_URL: $NEXTCLOUD_MCP_SERVER_URL"
```
### Start MCP Server
```bash
# Start with OAuth mode
uv run nextcloud-mcp-server --oauth
# Or with custom options
uv run nextcloud-mcp-server --oauth --port 8000 --log-level info
```
### Verify Startup
Look for these success messages:
**For Mode A (Auto-registration)**:
```
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
INFO Configuring MCP server for OAuth mode
INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration
✓ PKCE support validated: ['S256']
INFO OIDC discovery successful
INFO Attempting dynamic client registration...
INFO Dynamic client registration successful
INFO OAuth client ready: <client-id>...
INFO Saved OAuth client credentials to .nextcloud_oauth_client.json
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
**For Mode B (Pre-configured)**:
```
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
INFO Configuring MCP server for OAuth mode
INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration
✓ PKCE support validated: ['S256']
INFO OIDC discovery successful
INFO Using pre-configured OAuth client: abc123xyz789
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
### Common Startup Issues
| Issue | Solution |
|-------|----------|
| "OAuth mode requires NEXTCLOUD_HOST" | Set `NEXTCLOUD_HOST` in `.env` |
| "OIDC discovery failed" | Verify Nextcloud URL and network connectivity |
| "Dynamic registration failed" | Enable dynamic registration in OIDC app settings |
| "PKCE validation failed" | See [Upstream Status](oauth-upstream-status.md) |
See [OAuth Troubleshooting](oauth-troubleshooting.md) for detailed solutions.
## Testing Authentication
### Test with MCP Inspector
The MCP Inspector provides a web UI for testing:
```bash
# In a new terminal
uv run mcp dev
# Opens browser at http://localhost:6272
```
In the MCP Inspector UI:
1. Enter server URL: `http://localhost:8000/mcp`
2. Click **Connect**
3. Complete OAuth flow in browser popup:
- Login to Nextcloud
- Authorize MCP server access
- Redirected back to MCP Inspector
4. Test tools:
- Try `nc_notes_create_note`
- Try `nc_notes_search_notes`
- Try `nc_calendar_list_events`
### Test from Command Line
```bash
# Get an OAuth token (you'll need to implement client flow or extract from browser)
TOKEN="your_access_token_here"
# Test OCS API (should work)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
-H "OCS-APIRequest: true"
# Test Notes API (requires upstream patch)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
### Verify Token Validation
Check MCP server logs for token validation:
```bash
# Start server with debug logging
uv run nextcloud-mcp-server --oauth --log-level debug
# Look for:
# DEBUG Token validation via userinfo endpoint
# DEBUG Token validated successfully for user: username
```
## Production Recommendations
### Security Best Practices
1. **Use Pre-configured Clients** (Mode B)
- More stable
- Better audit trails
- No expiration issues
2. **Secure Credential Storage**
```bash
# Set restrictive permissions
chmod 600 .nextcloud_oauth_client.json
chmod 600 .env
```
3. **Use HTTPS for MCP Server**
- Especially important for remote access
- Use reverse proxy (nginx, Apache) with SSL
4. **Restrict Redirect URIs**
- Only register necessary redirect URIs
- Use specific URLs (not wildcards)
### Deployment Considerations
1. **MCP Server URL**
- Must be accessible to OAuth clients
- Must match redirect URI registered with Nextcloud
- For Docker: expose port and use correct host
2. **Network Configuration**
- MCP server must reach Nextcloud (OIDC endpoints)
- OAuth clients must reach MCP server (callbacks)
- OAuth clients must reach Nextcloud (authorization flow)
3. **Process Management**
- Use systemd, supervisord, or Docker for MCP server
- Ensure automatic restart on failure
- Monitor logs for OAuth errors
### Example Production Configs
#### Docker Compose
```yaml
version: '3'
services:
nextcloud-mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
ports:
- "127.0.0.1:8000:8000"
environment:
NEXTCLOUD_HOST: https://your.nextcloud.instance.com
NEXTCLOUD_OIDC_CLIENT_ID: ${NEXTCLOUD_OIDC_CLIENT_ID}
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
volumes:
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
command: ["--oauth", "--transport", "streamable-http"]
restart: unless-stopped
```
#### Systemd Service
```ini
[Unit]
Description=Nextcloud MCP Server (OAuth)
After=network.target
[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/nextcloud-mcp-server
Environment="NEXTCLOUD_HOST=https://your.nextcloud.instance.com"
Environment="NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789"
Environment="NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012"
Environment="NEXTCLOUD_MCP_SERVER_URL=http://your-server:8000"
ExecStart=/opt/nextcloud-mcp-server/.venv/bin/nextcloud-mcp-server --oauth
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### Monitoring and Maintenance
1. **Log Monitoring**
```bash
# Watch for OAuth errors
tail -f /var/log/nextcloud-mcp/server.log | grep -i "oauth\|token"
```
2. **Token Expiration** (Mode A only)
- Monitor for "Stored client has expired" messages
- Consider increasing expiration or switching to Mode B
3. **Upstream Patches**
- Subscribe to [Upstream Status](oauth-upstream-status.md)
- Plan to update when patches are merged
## Troubleshooting
For OAuth-specific issues, see [OAuth Troubleshooting](oauth-troubleshooting.md).
Common issues:
- [OIDC discovery failed](oauth-troubleshooting.md#oidc-discovery-failed)
- [Bearer token auth fails](oauth-troubleshooting.md#bearer-token-authentication-fails)
- [Client expired](oauth-troubleshooting.md#client-expired)
- [PKCE errors](oauth-troubleshooting.md#pkce-not-advertised)
## Next Steps
- [OAuth Architecture](oauth-architecture.md) - Understand how OAuth works
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Solve common issues
- [Upstream Status](oauth-upstream-status.md) - Track required patches
- [Configuration](configuration.md) - All environment variables
- [Running the Server](running.md) - Additional server options
## See Also
- [Authentication Overview](authentication.md) - OAuth vs BasicAuth comparison
- [Quick Start Guide](quickstart-oauth.md) - 5-minute setup for development
- [MCP Specification](https://spec.modelcontextprotocol.io/) - MCP protocol details
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Framework
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE Extension
+554
View File
@@ -0,0 +1,554 @@
# OAuth Troubleshooting
This guide covers OAuth-specific issues and solutions for the Nextcloud MCP server.
For general troubleshooting, see [Troubleshooting Guide](troubleshooting.md).
## Quick Diagnosis
Start here to identify your issue:
| Symptom | Likely Cause | Quick Fix Link |
|---------|--------------|----------------|
| "OAuth mode requires NEXTCLOUD_HOST" | Missing environment variable | [Missing NEXTCLOUD_HOST](#missing-nextcloud_host) |
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
## Configuration Issues
### Missing NEXTCLOUD_HOST
**Error Message**:
```
OAuth mode requires NEXTCLOUD_HOST environment variable
```
**Cause**: The `NEXTCLOUD_HOST` environment variable is not set or empty.
**Solution**:
1. Add to your `.env` file:
```bash
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
```
2. Reload environment variables:
```bash
export $(grep -v '^#' .env | xargs)
```
3. Verify it's set:
```bash
echo $NEXTCLOUD_HOST
# Should output: https://your.nextcloud.instance.com
```
---
### Missing or Misconfigured OIDC Apps
**Error Message**:
```
OAuth mode requires either client credentials OR dynamic client registration
```
**Cause**: The required Nextcloud OIDC apps are either:
- Not installed
- Not enabled
- Missing configuration
**Solution**:
**Step 1**: Verify both apps are installed:
```bash
# Check installed apps
php occ app:list | grep -E "oidc|user_oidc"
# Should show:
# - oidc: enabled
# - user_oidc: enabled
```
If not installed:
1. Open Nextcloud as administrator
2. Navigate to **Apps** → **Security**
3. Install **"OIDC"** (OIDC Identity Provider)
4. Install **"OpenID Connect user backend"** (user_oidc)
5. Enable both apps
**Step 2**: Enable dynamic client registration:
1. Go to **Settings** → **OIDC** (Administration)
2. Enable **"Allow dynamic client registration"**
**Step 3**: Configure Bearer token validation:
```bash
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
**Step 4**: Verify discovery endpoint:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
# Should output:
# "https://your.nextcloud.instance.com/apps/oidc/register"
```
**Alternative**: Use pre-configured client credentials:
```bash
# Register client via CLI
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
echo "NEXTCLOUD_OIDC_CLIENT_ID=<client-id>" >> .env
echo "NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>" >> .env
```
---
### Client Expired
**Error Message**:
```
Stored client has expired
```
**Cause**: Dynamically registered OAuth clients expire (default: 1 hour).
**Solution**:
**Option 1: Restart the Server** (Automatic re-registration)
```bash
uv run nextcloud-mcp-server --oauth
# Server automatically re-registers if credentials expired
```
**Option 2: Use Pre-configured Credentials** (Recommended for production)
```bash
# Register permanent client via Nextcloud CLI
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
NEXTCLOUD_OIDC_CLIENT_ID=<from-output>
NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>
```
Pre-configured clients don't expire.
**Option 3: Increase Expiration Time**
```bash
# Via Nextcloud CLI (default: 3600 seconds = 1 hour)
php occ config:app:set oidc expire_time --value "86400" # 24 hours
```
---
### File Permission Error
**Error Message**:
```
Permission denied when reading/writing .nextcloud_oauth_client.json
```
**Cause**: The server cannot access the OAuth client storage file.
**Solution**:
```bash
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Fix file permissions (owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Ensure directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
# If file doesn't exist, ensure directory is writable
mkdir -p $(dirname .nextcloud_oauth_client.json)
```
For custom storage paths:
```bash
# Set custom path in .env
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
# Ensure directory exists and is writable
mkdir -p $(dirname /path/to/custom/oauth_client.json)
chmod 755 $(dirname /path/to/custom/oauth_client.json)
```
---
## Discovery and Connection Issues
### OIDC Discovery Failed
**Error Message**:
```
OIDC discovery failed
Cannot reach OIDC discovery endpoint
```
**Cause**: The server cannot reach the Nextcloud OIDC discovery endpoint.
**Solution**:
**Step 1**: Verify Nextcloud URL is correct:
```bash
echo $NEXTCLOUD_HOST
# Should be full URL: https://your.nextcloud.instance.com
```
**Step 2**: Test discovery endpoint manually:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
# Should return JSON with OIDC configuration
# {
# "issuer": "https://your.nextcloud.instance.com",
# "authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize",
# ...
# }
```
**Step 3**: Check network connectivity:
```bash
# Test basic connectivity
ping your.nextcloud.instance.com
# Test HTTPS
curl -I https://your.nextcloud.instance.com
```
**Step 4**: Verify both OIDC apps are enabled:
```bash
php occ app:list | grep -E "oidc|user_oidc"
```
**Step 5**: Check firewall rules (if using Docker):
```bash
# Check if MCP server can reach Nextcloud
docker exec nextcloud-mcp-server curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
---
## Authentication Issues
### Bearer Token Authentication Fails
**Error Message**:
```
HTTP 401 Unauthorized when calling Nextcloud APIs
```
**Symptoms**:
- OCS APIs work (`/ocs/v2.php/cloud/capabilities`)
- App APIs fail (`/apps/notes/api/`, `/apps/calendar/`, etc.)
**Cause**: The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints.
**Solution**: Apply the Bearer token patch to `user_oidc` app.
See [Upstream Status](oauth-upstream-status.md#1-bearer-token-support-for-non-ocs-endpoints) for details.
**Quick Patch**:
```bash
# SSH into Nextcloud server
cd /path/to/nextcloud/apps/user_oidc
# Edit lib/User/Backend.php
# Add this line before each return statement in getCurrentUserId() method:
$this->session->set('app_api', true);
# Lines to modify: ~243, ~310, ~315, ~337
```
**Test the fix**:
```bash
# Get an OAuth token (from MCP client or test)
TOKEN="your_access_token"
# Test Notes API
curl -H "Authorization: Bearer $TOKEN" \
https://your.nextcloud.instance.com/apps/notes/api/v1/notes
# Should return notes JSON (not 401)
```
---
### PKCE Not Advertised
**Error Message**:
```
ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement
⚠️ MCP clients (like Claude Code) WILL REJECT this provider!
```
**Cause**: The OIDC discovery endpoint doesn't include `code_challenge_methods_supported` field.
**Impact**:
- Some MCP clients may refuse to connect
- Standards compliance issue (RFC 8414)
- **Functionality still works** (PKCE is accepted, just not advertised)
**Solution**:
**Short-term**: The MCP server logs a warning but continues. OAuth flow still works.
**Long-term**: Update the `oidc` app to advertise PKCE support.
See [Upstream Status](oauth-upstream-status.md#2-pkce-support-advertisement-in-discovery) for tracking.
**Verify**:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.code_challenge_methods_supported'
# Should return:
# ["S256", "plain"]
# If null, PKCE isn't advertised (but still works)
```
---
## Runtime Issues
### MCP Client Can't Authenticate
**Symptoms**:
- Client connects but OAuth flow fails
- Authorization redirects don't work
- Token exchange fails
**Diagnosis**:
**Step 1**: Verify OAuth is configured correctly:
```bash
uv run nextcloud-mcp-server --oauth --log-level debug
```
Look for:
```
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
**Step 2**: Check OIDC discovery:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
**Step 3**: Verify MCP server URL matches client expectations:
```bash
echo $NEXTCLOUD_MCP_SERVER_URL
# Should match the URL clients use to connect
# Default: http://localhost:8000
```
If MCP server is on a different host/port, update:
```bash
NEXTCLOUD_MCP_SERVER_URL=http://actual-host:actual-port
```
**Step 4**: Check redirect URI configuration:
For pre-configured clients, ensure redirect URI matches:
```bash
# Client redirect URI should be:
http://your-mcp-server-url/oauth/callback
# Example for local server:
http://localhost:8000/oauth/callback
```
---
### Tools Return 401 Errors
**Symptoms**:
- OAuth flow completes successfully
- Token is valid
- MCP tools return 401 errors
**Cause**: Bearer token not working with Nextcloud APIs.
**Solution**: See [Bearer Token Authentication Fails](#bearer-token-authentication-fails) above.
---
## Switching Authentication Modes
### From BasicAuth to OAuth
```bash
# 1. Remove or comment out USERNAME/PASSWORD in .env
sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env
sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env
# 2. Ensure NEXTCLOUD_HOST is set
grep NEXTCLOUD_HOST .env
# 3. Restart server with OAuth
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --oauth
```
### From OAuth to BasicAuth
```bash
# 1. Add USERNAME/PASSWORD to .env
echo "NEXTCLOUD_USERNAME=your-username" >> .env
echo "NEXTCLOUD_PASSWORD=your-password" >> .env
# 2. Restart server (BasicAuth auto-detected)
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --no-oauth
```
---
## Advanced Debugging
### Enable Debug Logging
```bash
uv run nextcloud-mcp-server --oauth --log-level debug
```
Look for:
- OIDC discovery details
- Client registration attempts
- Token validation logs
- API request/response details
### Test Discovery Endpoint
```bash
# Full discovery response
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq
# Check specific fields
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '{
issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
registration_endpoint,
code_challenge_methods_supported
}'
```
### Test Token Validation
```bash
# Get userinfo with token
curl -H "Authorization: Bearer $TOKEN" \
https://your.nextcloud.instance.com/apps/oidc/userinfo
# Should return user info:
# {
# "sub": "username",
# "preferred_username": "username",
# "name": "Display Name",
# ...
# }
```
### Test Nextcloud API Access
```bash
# Test OCS API (should work)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
-H "OCS-APIRequest: true"
# Test app API (requires patch)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
---
## Getting Help
If you continue to experience issues:
### 1. Collect Diagnostic Information
```bash
# Server version
uv run nextcloud-mcp-server --version
# Python version
python3 --version
# Server logs with debug
uv run nextcloud-mcp-server --oauth --log-level debug 2>&1 | tee mcp-server.log
# OIDC discovery
curl https://your.nextcloud.instance.com/.well-known/openid-configuration > oidc-discovery.json
# Nextcloud version
# Check in Nextcloud admin panel or:
php occ -V
```
### 2. Check Documentation
- [OAuth Architecture](oauth-architecture.md) - How OAuth works
- [OAuth Setup Guide](oauth-setup.md) - Configuration steps
- [Upstream Status](oauth-upstream-status.md) - Required patches
- [Configuration](configuration.md) - Environment variables
### 3. Open an Issue
If problems persist, [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with:
- **Error messages** (full text)
- **Server logs** (with `--log-level debug`)
- **OIDC discovery response** (from curl command above)
- **Nextcloud version**
- **OIDC app versions** (`oidc` and `user_oidc`)
- **Steps to reproduce**
- **Environment details** (OS, Python version, Docker vs local)
---
## See Also
- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly
- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration
- [OAuth Architecture](oauth-architecture.md) - Technical details
- [Upstream Status](oauth-upstream-status.md) - Required patches
- [General Troubleshooting](troubleshooting.md) - Non-OAuth issues
+242
View File
@@ -0,0 +1,242 @@
# OAuth Upstream Status
This document tracks the status of upstream patches and pull requests required for full OAuth functionality.
## Overview
The Nextcloud MCP Server's OAuth implementation relies on two Nextcloud apps:
- **`oidc`** - OIDC Identity Provider (Authorization Server)
- **`user_oidc`** - OpenID Connect user backend (Token validation)
While the core OAuth flow works, there are **pending upstream improvements** that enhance functionality and standards compliance.
## Required Patches
### 1. Bearer Token Support for Non-OCS Endpoints
**Status**: 🟡 **Patch Required** (Pending Upstream)
**Affected Component**: `user_oidc` app
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
**Impact**:
-**Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
-**Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
**Patch Summary**:
```php
// Add before successful Bearer token authentication returns
$this->session->set('app_api', true);
```
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
---
### 2. PKCE Support (RFC 7636)
**Status**: ✅ **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
**Authorization Endpoint** (`/authorize`):
- Accepts `code_challenge` and `code_challenge_method` parameters
- Validates code_challenge format (43-128 characters, unreserved chars only)
- Supports both `S256` (SHA-256) and `plain` challenge methods
- Stores challenge and method in database for later verification
**Token Endpoint** (`/token`):
- Accepts `code_verifier` parameter
- Verifies code_verifier against stored code_challenge using proper algorithm
- Uses constant-time comparison to prevent timing attacks
- Enforces code_verifier requirement when PKCE was used in authorization
**Discovery Document**:
```json
{
"code_challenge_methods_supported": ["S256", "plain"]
}
```
**Database**:
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
- Migration included for existing installations
**Why It Mattered**:
- MCP specification requires PKCE with S256 code challenge method
- RFC 7636 PKCE provides security for public clients (no client secret)
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
- Prevents authorization code interception attacks
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
- **Changes**: Complete PKCE implementation (+194 lines)
- Authorization flow with code_challenge validation
- Token exchange with code_verifier verification
- Database schema updates
- Discovery document updates
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
---
## Upstream PRs Status
| PR/Issue | Component | Status | Priority | Notes |
|----------|-----------|--------|----------|-------|
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
## What Works Without Patches
The following functionality works **out of the box** without any patches:
**OAuth Flow**:
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
- Dynamic client registration
- Authorization code flow with PKCE (S256 and plain methods)
- Token exchange with code_verifier verification
- Userinfo endpoint
**MCP Server as Resource Server**:
- Token validation via userinfo
- Per-user client instances
- Token caching
**Nextcloud OCS APIs**:
- Capabilities endpoint
- All OCS-based APIs
## What Requires Patches
The following functionality requires upstream patches:
🟡 **App-Specific APIs** (Requires user_oidc#1221):
- Notes API (`/apps/notes/api/`)
- Calendar API (CalDAV)
- Contacts API (CardDAV)
- Deck API
- Tables API
- Custom app APIs
**Standards Compliance**: Now complete with `oidc` app v1.10.0+
- ✅ Full RFC 8414 compliance (PKCE advertisement)
- ✅ MCP client compatibility guarantee
## Installation Instructions
### For Development/Testing
If the upstream PRs are not yet merged, you can apply patches manually:
#### 1. Apply Bearer Token Patch
```bash
# SSH into Nextcloud server
cd /path/to/nextcloud/apps/user_oidc
# Download and apply patch
# (Patch file to be created once PR is ready)
wget https://github.com/nextcloud/user_oidc/pull/XXXX.patch
git apply XXXX.patch
# Or manually edit lib/User/Backend.php
# Add this line before each return statement in getCurrentUserId():
# $this->session->set('app_api', true);
```
#### 2. Verify Installation
```bash
# Test with OAuth token
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your.nextcloud.com/apps/notes/api/v1/notes
# Should return notes JSON (not 401)
```
### For Production
**Recommendation**: Wait for upstream PRs to be merged and included in official Nextcloud releases before deploying OAuth in production.
**Alternative**: Use a patched version of `user_oidc` app in your deployment:
1. Fork the `user_oidc` app
2. Apply the required patches
3. Install your patched version
4. Document the changes for your team
## Testing
The integration test suite validates OAuth functionality:
```bash
# Start OAuth-enabled MCP server
docker-compose up --build -d mcp-oauth
# Run comprehensive OAuth tests
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
# Tests verify:
# - OAuth flow completion
# - Token validation
# - MCP tool calls with Bearer tokens
# - Notes API access (requires patch)
```
## Monitoring Upstream Progress
To track progress on these issues:
1. **Watch the upstream repositories**:
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
2. **Subscribe to specific issues**:
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
3. **Check Nextcloud release notes** for mentions of:
- Bearer token authentication improvements
- OIDC/OAuth enhancements
- AppAPI compatibility
## Contributing
Want to help get these patches merged?
1. **Test the patches**: Run the integration tests and report results
2. **Review PRs**: Provide feedback on upstream pull requests
3. **Document issues**: Report any problems or edge cases
4. **Contribute code**: Submit improvements or fixes to upstream
## Timeline Expectations
**Best Case**: PRs merged in next Nextcloud minor release (est. 3-6 months)
**Realistic**: PRs reviewed and merged within 6-12 months
**Meanwhile**: Use the workarounds documented in this guide
## See Also
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in this implementation
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions
- [OAuth Setup Guide](oauth-setup.md) - Configuration instructions
---
**Last Updated**: 2025-10-20
**Next Review**: When issue #1221 (Bearer token support) has activity
+163
View File
@@ -0,0 +1,163 @@
# OAuth Quick Start Guide
Get up and running with OAuth authentication in 5 minutes.
## Prerequisites Checklist
Before you begin, ensure you have:
- [ ] Nextcloud instance with **administrator access**
- [ ] Nextcloud version 28 or later
- [ ] Python 3.11+ installed
- [ ] `uv` package manager installed ([installation instructions](https://docs.astral.sh/uv/getting-started/installation/))
## Step 1: Install Nextcloud Apps
Install **both** required apps in your Nextcloud instance:
1. Open Nextcloud as administrator
2. Navigate to **Apps****Security**
3. Install:
- **OIDC** (OIDC Identity Provider app)
- **OpenID Connect user backend** (user_oidc app)
4. Enable both apps
> [!IMPORTANT]
> The `user_oidc` app requires an upstream patch for Bearer token support. See [Upstream Status](oauth-upstream-status.md) for details. The functionality works, but the PR is pending.
## Step 2: Configure Nextcloud OIDC
Enable dynamic client registration and Bearer token validation:
### Via Web UI
1. Go to **Settings****OIDC** (Administration settings)
2. Enable **"Allow dynamic client registration"**
### Via CLI (Required)
SSH into your Nextcloud server and run:
```bash
# Enable Bearer token validation
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
## Step 3: Install MCP Server
Clone and install the MCP server:
```bash
# Clone repository
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
# Install dependencies
uv sync
```
## Step 4: Configure Environment
Create a `.env` file with minimal configuration:
```bash
# Copy sample
cp env.sample .env
# Edit .env and set:
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# IMPORTANT: Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
```
## Step 5: Start the Server
Load environment variables and start the server:
```bash
# Load environment
export $(grep -v '^#' .env | xargs)
# Start server with OAuth
uv run nextcloud-mcp-server --oauth
```
Look for this success message:
```
✓ PKCE support validated: ['S256']
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
## Step 6: Test with MCP Inspector
Open a new terminal and test the connection:
```bash
# Start MCP Inspector
uv run mcp dev
```
This opens your browser. In the MCP Inspector UI:
1. Enter server URL: `http://127.0.0.1:8000/mcp`
2. Click **Connect**
3. Complete the OAuth flow in the browser popup
4. After authorization, you'll see available tools and resources
Test a tool by trying:
- **Tool**: `nc_notes_create_note`
- **Title**: "Test Note"
- **Content**: "Hello from MCP!"
- **Category**: "Notes"
## Troubleshooting Quick Fixes
### PKCE Error
If you see:
```
ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement
```
**Fix**: The Nextcloud OIDC app needs to be updated to advertise PKCE support. See [Upstream Status](oauth-upstream-status.md) for the required PR.
### 401 Unauthorized for Notes API
If OAuth works but Notes API returns 401:
**Fix**: The `user_oidc` app needs the Bearer token patch. See [Upstream Status](oauth-upstream-status.md) for details.
### Can't Reach OIDC Discovery Endpoint
**Fix**: Verify your Nextcloud URL is correct and accessible:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
## Next Steps
- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration options
- [OAuth Architecture](oauth-architecture.md) - How it works under the hood
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions
- [Configuration](configuration.md) - All environment variables
## Development vs Production
This quick start uses **automatic client registration** which is perfect for:
- Development
- Testing
- Quick deployments
For **production deployments**, consider:
1. Pre-registering OAuth client manually
2. Using dedicated client credentials that don't expire
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
---
**Need help?** Check [OAuth Troubleshooting](oauth-troubleshooting.md) or [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues).
+440
View File
@@ -0,0 +1,440 @@
# Running the Server
This guide covers different ways to start and run the Nextcloud MCP server.
## Prerequisites
Before running the server:
1. **Install the server** - See [Installation Guide](installation.md)
2. **Configure environment** - See [Configuration Guide](configuration.md)
3. **Set up authentication** - See [OAuth Setup](oauth-setup.md) or [Authentication](authentication.md)
---
## Quick Start
Load your environment variables and start the server:
```bash
# Load environment variables from .env
export $(grep -v '^#' .env | xargs)
# Start the server
uv run nextcloud-mcp-server
```
The server will start on `http://127.0.0.1:8000` by default.
---
## Running Locally
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
The CLI provides a simple interface with built-in defaults:
#### OAuth Mode
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
uv run nextcloud-mcp-server
# Explicitly force OAuth mode
uv run nextcloud-mcp-server --oauth
# OAuth with custom host and port
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
# OAuth with pre-configured client
uv run nextcloud-mcp-server --oauth \
--oauth-client-id abc123 \
--oauth-client-secret xyz789
# OAuth with specific apps only
uv run nextcloud-mcp-server --oauth \
--enable-app notes \
--enable-app calendar
```
#### BasicAuth Mode (Legacy)
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
uv run nextcloud-mcp-server
# Explicitly force BasicAuth mode
uv run nextcloud-mcp-server --no-oauth
# BasicAuth with specific apps
uv run nextcloud-mcp-server --no-oauth \
--enable-app notes \
--enable-app webdav
```
### Method 2: Using uvicorn
For more control over server options (workers, reload, etc.):
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run with uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--host 127.0.0.1 \
--port 8000 \
--reload # Enable auto-reload for development
```
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
### Method 3: Using Python Module
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run as Python module
python -m nextcloud_mcp_server.app --oauth --port 8000
```
---
## Running with Docker
### Basic Docker Run
```bash
# OAuth mode
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# BasicAuth mode
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Docker with Persistent OAuth Storage
```bash
docker run -p 127.0.0.1:8000:8000 --env-file .env \
-v $(pwd)/.oauth:/app/.oauth \
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
```
### Docker Compose
Create `docker-compose.yml`:
```yaml
version: '3.8'
services:
mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
command: --oauth --enable-app notes --enable-app calendar
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
volumes:
- ./oauth-storage:/app/.oauth
restart: unless-stopped
```
Start the service:
```bash
# Start in foreground
docker-compose up
# Start in background
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the service
docker-compose down
```
---
## Server Options
### Host and Port
```bash
# Bind to all interfaces (accessible from network)
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
# Bind to localhost only (default, more secure)
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
# Use a different port
uv run nextcloud-mcp-server --port 8080
```
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
### Transport Protocols
The server supports multiple MCP transport protocols:
```bash
# Streamable HTTP (recommended)
uv run nextcloud-mcp-server --transport streamable-http
# SSE - Server-Sent Events (default, deprecated)
uv run nextcloud-mcp-server --transport sse
# HTTP
uv run nextcloud-mcp-server --transport http
```
> [!WARNING]
> SSE transport is deprecated and will be removed in a future version of the MCP spec. Please migrate to `streamable-http`.
### Logging
```bash
# Set log level (critical, error, warning, info, debug, trace)
uv run nextcloud-mcp-server --log-level debug
# Production: use warning or error
uv run nextcloud-mcp-server --log-level warning
```
### Selective App Enablement
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
```bash
# Available apps: notes, tables, webdav, calendar, contacts, deck
# Enable all apps (default)
uv run nextcloud-mcp-server
# Enable only Notes
uv run nextcloud-mcp-server --enable-app notes
# Enable multiple apps
uv run nextcloud-mcp-server \
--enable-app notes \
--enable-app calendar \
--enable-app contacts
# Enable only WebDAV for file operations
uv run nextcloud-mcp-server --enable-app webdav
```
**Use cases:**
- Reduce memory usage and startup time
- Limit functionality for security/organizational reasons
- Test specific app integrations
- Run lightweight instances with only needed features
---
## Development Mode
For active development with auto-reload:
```bash
# Using uvicorn with reload
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--reload \
--host 127.0.0.1 \
--port 8000 \
--log-level debug
```
Or use the CLI with reload flag:
```bash
uv run nextcloud-mcp-server --reload --log-level debug
```
---
## Connecting to the Server
### Using MCP Inspector
MCP Inspector is a browser-based tool for testing MCP servers:
```bash
# Start MCP Inspector
uv run mcp dev
# In the browser:
# 1. Enter server URL: http://localhost:8000
# 2. Complete OAuth flow (if using OAuth)
# 3. Explore tools and resources
```
### Using MCP Clients
MCP clients (like Claude Desktop, LLM IDEs) can connect to your server:
1. Configure the client with your server URL
2. Complete OAuth authentication (if enabled)
3. Start interacting with Nextcloud through the LLM
---
## Verifying Server Status
### Check Server Health
```bash
# Test if server is responding
curl http://localhost:8000/health
# Expected response: HTTP 200 OK
```
### Check OAuth Configuration
Look for these log messages on startup:
**OAuth mode:**
```
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
INFO Configuring MCP server for OAuth mode
INFO OIDC discovery successful
INFO OAuth client ready: <client-id>...
INFO OAuth initialization complete
```
**BasicAuth mode:**
```
INFO BasicAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD set)
INFO Initializing Nextcloud client with BasicAuth
```
---
## Process Management
### Running as a Background Service
#### Using systemd (Linux)
Create `/etc/systemd/system/nextcloud-mcp.service`:
```ini
[Unit]
Description=Nextcloud MCP Server
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/nextcloud-mcp-server
EnvironmentFile=/path/to/.env
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable nextcloud-mcp
sudo systemctl start nextcloud-mcp
sudo systemctl status nextcloud-mcp
```
#### Using Docker Compose
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
### Monitoring Logs
```bash
# Local installation with systemd
sudo journalctl -u nextcloud-mcp -f
# Docker
docker logs -f <container-name>
# Docker Compose
docker-compose logs -f mcp
```
---
## Performance Tuning
### Multiple Workers
For production deployments with higher load:
```bash
# Using CLI (if supported)
uv run nextcloud-mcp-server --workers 4
# Using uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--workers 4 \
--host 0.0.0.0 \
--port 8000
```
### Production Settings
```bash
# Recommended production configuration
uv run nextcloud-mcp-server \
--oauth \
--host 127.0.0.1 \
--port 8000 \
--log-level warning \
--transport streamable-http \
--workers 2
```
---
## Troubleshooting
### Server won't start
Check logs for errors:
```bash
uv run nextcloud-mcp-server --log-level debug
```
Common issues:
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
- Port already in use - Try a different port with `--port`
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
### Can't connect to server
1. Verify server is running: `curl http://localhost:8000/health`
2. Check firewall settings
3. Verify host binding (use `0.0.0.0` to allow network access)
4. Check OAuth authentication if enabled
### OAuth authentication fails
See [Troubleshooting OAuth](troubleshooting.md) for detailed OAuth troubleshooting.
---
## See Also
- [Configuration Guide](configuration.md) - Environment variables
- [OAuth Setup](oauth-setup.md) - OAuth authentication setup
- [Troubleshooting](troubleshooting.md) - Common issues and solutions
- [Installation](installation.md) - Installing the server
+12
View File
@@ -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,317 @@
# Testing Client Sessions Architecture
## Overview
This document compares different approaches to managing MCP client sessions in integration tests, addressing the fundamental incompatibility between pytest-asyncio's fixture management and anyio's structured concurrency requirements.
## The Problem
When using pytest-asyncio with anyio-based libraries (like the MCP Python SDK), session-scoped async generator fixtures encounter a fundamental issue:
1. **pytest-asyncio** runs fixture teardown in a **new asyncio task** using `runner.run()`
2. **anyio** requires that cancel scopes be entered and exited in the **same task**
3. This causes `RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`
This is a **known limitation** documented in the anyio project and is not a bug in either pytest-asyncio or anyio, but rather an inherent incompatibility between their design philosophies.
## Solution Comparison
### Solution 1: Native Async Context Managers with Surgical Exception Handling ✅ **IMPLEMENTED**
**Approach**: Use native `async with` statements for clean code structure, but add targeted exception handling at the pytest fixture level to handle the expected teardown errors.
**Implementation**:
```python
async def create_mcp_client_session(
url: str,
token: str | None = None,
client_name: str = "MCP",
) -> AsyncGenerator[ClientSession, Any]:
"""Uses native async context managers for clean LIFO cleanup."""
headers = {"Authorization": f"Bearer {token}"} if token else None
async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""Fixture with surgical exception handling for pytest-asyncio incompatibility."""
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
):
yield session
except RuntimeError as e:
# Only catch the specific expected error during pytest teardown
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
# Unexpected RuntimeError - re-raise to fail the test
raise
```
**Pros**:
- ✅ Clean, idiomatic code using native Python context managers
- ✅ Exception handling is surgical - only catches the specific expected error
- ✅ Unexpected errors still propagate and fail tests
- ✅ Can use session-scoped fixtures for performance
- ✅ Easy to understand and maintain
- ✅ Minimal code changes from original implementation
- ✅ No external dependencies required
**Cons**:
- ⚠️ Still requires exception suppression (though targeted)
- ⚠️ String-based exception matching is somewhat fragile
- ⚠️ Must apply the pattern to each session-scoped fixture
- ⚠️ Doesn't solve the root cause
**Verdict**: **Recommended** - Best balance of code clarity, maintainability, and pragmatism.
---
### Solution 2: Task-Isolated Fixtures
**Approach**: Run each fixture's client session in an isolated anyio task group, allowing independent cleanup without cross-fixture interference.
**Implementation**:
```python
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""Fixture with task isolation for clean teardown."""
import anyio
session_holder = {"session": None}
async def create_and_hold_session():
"""Runs in isolated task - creates session and keeps it alive."""
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_holder["session"] = session
# Keep session alive until cancelled
try:
await anyio.sleep_forever()
except anyio.get_cancelled_exc_class():
pass # Expected cancellation
async with anyio.create_task_group() as tg:
tg.start_soon(create_and_hold_session)
# Wait for session to be ready
while session_holder["session"] is None:
await anyio.sleep(0.1)
yield session_holder["session"]
# Task group cancellation ensures clean LIFO cleanup
tg.cancel_scope.cancel()
```
**Pros**:
- ✅ No exception suppression needed
- ✅ Each fixture has its own isolated task scope
- ✅ More theoretically correct approach
- ✅ Can use session-scoped fixtures
**Cons**:
- ❌ Significantly more complex code
- ❌ Harder to understand for developers unfamiliar with anyio
- ❌ Requires understanding of task groups and cancel scopes
- ❌ More boilerplate per fixture
- ❌ Still doesn't solve the fundamental pytest-asyncio incompatibility
- ❌ Polling for session readiness is inelegant
- ❌ Higher cognitive overhead for maintenance
**Verdict**: **Not Recommended** - Complexity outweighs benefits. Consider only if exception handling is completely unacceptable.
---
### Solution 3: Function-Scoped Fixtures with Nested Context Managers
**Approach**: Change fixtures to function scope and rely on Python's context manager nesting for guaranteed LIFO cleanup.
**Implementation**:
```python
@pytest.fixture(scope="function") # Changed from session
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""Function-scoped fixture with natural LIFO cleanup."""
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
# For tests needing multiple clients:
@pytest.fixture(scope="function")
async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSession], Any]:
"""Multiple clients with guaranteed LIFO cleanup through nesting."""
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read1, write1, _):
async with ClientSession(read1, write1) as session1:
await session1.initialize()
async with streamablehttp_client("http://127.0.0.1:8001/mcp") as (read2, write2, _):
async with ClientSession(read2, write2) as session2:
await session2.initialize()
yield session1, session2
# Cleanup: session2 -> stream2 -> session1 -> stream1 (LIFO guaranteed)
```
**Pros**:
- ✅ No exception handling needed
- ✅ Simplest to understand
- ✅ Natural LIFO cleanup through Python's context managers
- ✅ Each test gets fresh clients (better isolation)
- ✅ No workarounds or hacks required
**Cons**:
- ❌ Significantly slower tests (new clients per test)
- ❌ Cannot share client state across tests
- ❌ More resource intensive
- ❌ Higher overhead for test suite execution
- ❌ May not be practical for expensive fixtures (e.g., OAuth tokens)
- ❌ Nested context managers become unwieldy with many clients
**Verdict**: **Good Alternative** - Consider for specific fixtures where session scope isn't critical, or for new test files where performance isn't a concern.
---
### Solution 4: Use pytest-trio Instead of pytest-asyncio (Future)
**Approach**: Replace pytest-asyncio with pytest-trio, which was designed with structured concurrency in mind.
**Implementation**:
```python
# pyproject.toml
[tool.pytest.ini_options]
# Remove: asyncio_mode = "auto"
# Add: trio_mode = "auto"
# Fixtures work naturally with trio
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
```
**Pros**:
- ✅ No workarounds needed
- ✅ Designed for structured concurrency
- ✅ Theoretically cleanest solution
- ✅ Can use session-scoped fixtures naturally
**Cons**:
- ❌ Requires switching from asyncio to trio backend
- ❌ Major refactoring required
- ❌ May break existing code that assumes asyncio
- ❌ Dependency changes throughout project
- ❌ Team needs to learn trio ecosystem
- ❌ Less ecosystem support than asyncio
**Verdict**: **Not Practical** - Too disruptive for existing projects. Consider only for greenfield projects or major rewrites.
---
## Decision Matrix
| Solution | Code Clarity | Maintenance | Performance | Safety | Effort |
|----------|--------------|-------------|-------------|--------|--------|
| **Solution 1** (Implemented) | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Solution 2 (Task-Isolated) | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Solution 3 (Function-Scoped) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Solution 4 (pytest-trio) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
## Implementation Details
### What Changed in Solution 1
1. **`create_mcp_client_session` function** (conftest.py:61-110):
- Replaced manual `__aenter__`/`__aexit__` calls with native `async with` statements
- Removed blanket exception suppression from cleanup logic
- Added clear documentation about LIFO cleanup order
- Simplified from ~60 lines to ~40 lines
2. **Session-scoped MCP client fixtures** (conftest.py:148-1269):
- Added targeted exception handling wrapper
- Only catches specific "cancel scope" + "different task" RuntimeError
- All other exceptions propagate normally
- Applied to: `nc_mcp_client`, `nc_mcp_oauth_client`, `alice_mcp_client`, `bob_mcp_client`, `charlie_mcp_client`, `diana_mcp_client`
3. **Documentation**:
- Added comprehensive docstrings explaining the workaround
- Referenced MCP SDK issue #577 for context
- Documented why this is necessary and not a bug
### Benefits of This Implementation
1. **Clean Core Logic**: The `create_mcp_client_session` function is now clean, idiomatic Python with no workarounds
2. **Isolated Workaround**: Exception handling is confined to pytest fixture level where the issue actually occurs
3. **Surgical Exception Handling**: Only catches the specific expected error, not all RuntimeErrors
4. **Performance**: Maintains session-scoped fixtures for fast test execution
5. **Maintainability**: Easy to understand and modify
6. **Safety**: Real errors still cause test failures
## Testing Results
All tests pass cleanly with the implementation:
```bash
$ uv run pytest tests/server/test_mcp.py -v
============================================= test session starts ==============================================
tests/server/test_mcp.py::test_mcp_connectivity PASSED [ 16%]
tests/server/test_mcp.py::test_mcp_notes_crud_workflow PASSED [ 33%]
tests/server/test_mcp.py::test_mcp_notes_etag_conflict PASSED [ 50%]
tests/server/test_mcp.py::test_mcp_webdav_workflow PASSED [ 66%]
tests/server/test_mcp.py::test_mcp_resources_access PASSED [ 83%]
tests/server/test_mcp.py::test_mcp_calendar_workflow PASSED [100%]
============================================== 6 passed in 39.52s ==============================================
```
## Recommendations
### For This Project: Solution 1 ✅
The implemented solution (Solution 1) is the best fit because:
- Minimal disruption to existing tests
- Clean, maintainable code
- Good performance with session-scoped fixtures
- Targeted exception handling that doesn't hide real errors
### For New Test Files: Consider Solution 3
For new test files where performance isn't critical, consider using function-scoped fixtures (Solution 3):
- No workarounds needed
- Perfect code clarity
- Better test isolation
### For Greenfield Projects: Consider Solution 4
For new projects starting from scratch, consider pytest-trio instead of pytest-asyncio:
- Native structured concurrency support
- No workarounds needed
- Better alignment with modern async Python patterns
## Related Resources
- [MCP Python SDK Issue #577](https://github.com/modelcontextprotocol/python-sdk/issues/577) - Original issue report
- [Anyio Issue #345](https://github.com/agronholm/anyio/issues/345) - Discussion of fixture limitations
- [Nextcloud MCP Note 378555](nextcloud://notes/378555) - Detailed investigation notes
- pytest-asyncio documentation: https://pytest-asyncio.readthedocs.io/
- anyio structured concurrency guide: https://anyio.readthedocs.io/en/stable/basics.html
## Appendix: Why Can't This Be Fixed Upstream?
The incompatibility cannot be "fixed" in either pytest-asyncio or anyio without breaking their core design:
1. **pytest-asyncio** needs to manage fixture lifecycle across different scopes, requiring separate task creation for cleanup
2. **anyio** enforces structured concurrency guarantees by requiring same-task cancel scope entry/exit
3. These requirements are fundamentally incompatible
The maintainers of both projects are aware of this issue, and it's considered an acceptable trade-off given their respective design goals. The recommended approach is to handle it at the application level, as we've done here.
+556
View File
@@ -0,0 +1,556 @@
# Troubleshooting
This guide covers common issues and solutions for the Nextcloud MCP server.
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
## OAuth Issues (Quick Reference)
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
**Cause:** The `NEXTCLOUD_HOST` environment variable is not set or empty.
**Solution:**
```bash
# Ensure NEXTCLOUD_HOST is set in your .env file
echo "NEXTCLOUD_HOST=https://your.nextcloud.instance.com" >> .env
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Verify it's set
echo $NEXTCLOUD_HOST
```
---
### Issue: "OAuth mode requires either client credentials OR dynamic client registration"
**Cause:** The required Nextcloud OIDC apps are either:
1. Not installed (both `oidc` and `user_oidc` apps are required)
2. Don't have dynamic client registration enabled
3. Aren't providing a registration endpoint
**Solution:**
**Option 1: Enable dynamic client registration**
1. Verify **both** OIDC apps are installed:
- Navigate to Nextcloud **Apps** → **Security**
- Install **"OIDC"** (OIDC Identity Provider app) if not present
- Install **"OpenID Connect user backend"** (user_oidc app) if not present
2. Enable dynamic client registration:
- Go to **Settings****OIDC** (Administration)
- Enable "Allow dynamic client registration"
3. Configure Bearer token validation:
```bash
# Required for user_oidc app to validate tokens
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
3. Verify the registration endpoint exists:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
# Should output: "https://your.nextcloud.instance.com/apps/oidc/register"
```
**Option 2: Provide pre-configured credentials**
Register a client and add credentials to `.env`:
```bash
# On your Nextcloud server
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
echo "NEXTCLOUD_OIDC_CLIENT_ID=<from-output>" >> .env
echo "NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>" >> .env
```
See [OAuth Setup Guide](oauth-setup.md) for detailed instructions.
---
### Issue: "Stored client has expired"
**Cause:** Dynamically registered OAuth clients expire (default: 1 hour).
**Solution:**
**Option 1: Restart the server** (automatic re-registration)
```bash
# Server checks credentials at startup and re-registers if expired
uv run nextcloud-mcp-server --oauth
```
**Option 2: Use pre-configured credentials** (recommended for production)
```bash
# Register permanent client via Nextcloud CLI
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
NEXTCLOUD_OIDC_CLIENT_ID=<from-output>
NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>
```
**Option 3: Increase expiration time**
```bash
# Via Nextcloud occ command (default: 3600 seconds)
php occ config:app:set oidc expire_time --value "86400" # 24 hours
```
---
### Issue: "HTTP 401 Unauthorized" when calling Nextcloud APIs
**Cause:** OAuth Bearer tokens may not work with certain Nextcloud endpoints due to session handling in the CORS middleware.
**Background:** The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints (like Notes API). This affects app-specific APIs but not OCS APIs.
**Solution:**
A patch for the `user_oidc` app is required to fix Bearer token support. See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for:
- Detailed explanation of the issue
- Patch to apply to the `user_oidc` app
- Link to upstream pull request
**Affected endpoints:**
- Notes API (`/apps/notes/api/`)
- Other app-specific endpoints
**Unaffected endpoints:**
- OCS APIs (`/ocs/v2.php/`)
- Capabilities endpoint
---
### Issue: "Permission denied" when reading/writing OAuth client credentials file
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
**Solution:**
```bash
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Fix file permissions (should be 0600 - owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Ensure the directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
# If the file doesn't exist, ensure the directory is writable so it can be created
mkdir -p $(dirname .nextcloud_oauth_client.json)
```
---
### Issue: "OIDC discovery failed" or "Cannot reach OIDC discovery endpoint"
**Cause:** The server cannot reach the Nextcloud OIDC discovery endpoint.
**Solution:**
1. Verify the Nextcloud URL is correct:
```bash
echo $NEXTCLOUD_HOST
# Should be the full URL: https://your.nextcloud.instance.com
```
2. Test the discovery endpoint manually:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
# Should return JSON with OIDC configuration
```
3. Check network connectivity:
```bash
ping your.nextcloud.instance.com
```
4. Verify **both** OIDC apps are installed and enabled in Nextcloud:
- `oidc` - OIDC Identity Provider
- `user_oidc` - OpenID Connect user backend
5. Check firewall rules if using Docker
---
### Switching Between OAuth and BasicAuth
#### To switch from BasicAuth to OAuth:
```bash
# 1. Remove or comment out USERNAME/PASSWORD in .env
sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env
sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env
# 2. Ensure NEXTCLOUD_HOST is set
grep NEXTCLOUD_HOST .env
# 3. Restart server with OAuth
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --oauth
```
#### To switch from OAuth to BasicAuth:
```bash
# 1. Add USERNAME/PASSWORD to .env
echo "NEXTCLOUD_USERNAME=your-username" >> .env
echo "NEXTCLOUD_PASSWORD=your-password" >> .env
# 2. Restart server (BasicAuth auto-detected, or use --no-oauth)
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --no-oauth
```
---
### For More OAuth Help
See the dedicated **[OAuth Troubleshooting Guide](oauth-troubleshooting.md)** for:
- Bearer token authentication failures
- PKCE validation errors
- Token validation issues
- Client registration problems
- Advanced OAuth debugging
- And much more...
---
## Configuration Issues
### Issue: Environment variables not loaded
**Cause:** Environment variables from `.env` file are not loaded into the shell.
**Solution:**
**On Linux/macOS:**
```bash
# Load all variables from .env
export $(grep -v '^#' .env | xargs)
# Verify variables are set
env | grep NEXTCLOUD
```
**On Windows (PowerShell):**
```powershell
# Load variables from .env
Get-Content .env | ForEach-Object {
if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
}
}
# Verify variables are set
Get-ChildItem Env:NEXTCLOUD*
```
**With Docker:**
```bash
# Docker automatically loads .env when using --env-file
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
---
### Issue: ".env file not found"
**Cause:** The `.env` file doesn't exist or is in the wrong location.
**Solution:**
```bash
# Create .env from sample
cp env.sample .env
# Edit with your Nextcloud details
nano .env # or vim, code, etc.
# Ensure you're in the correct directory when running commands
pwd # Should be in the project directory containing .env
```
---
### Issue: "Invalid Nextcloud credentials"
**Cause:** BasicAuth credentials are incorrect or the app password has been revoked.
**Solution:**
1. **Verify username:**
```bash
# Username should match your Nextcloud login
echo $NEXTCLOUD_USERNAME
```
2. **Generate a new app password:**
- Log in to Nextcloud
- Go to **Settings** → **Security**
- Under "Devices & sessions", create a new app password
- Update `.env` with the new password
3. **Test credentials manually:**
```bash
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \
-H "OCS-APIRequest: true"
# Should return XML with capabilities
```
---
## Server Issues
### Issue: "Address already in use" / Port conflict
**Cause:** Another process is using port 8000.
**Solution:**
**Option 1: Use a different port**
```bash
uv run nextcloud-mcp-server --port 8080
```
**Option 2: Find and kill the process using the port**
```bash
# On Linux/macOS
lsof -ti:8000 | xargs kill -9
# On Windows
netstat -ano | findstr :8000
taskkill /PID <pid> /F
```
**Option 3: Stop other MCP server instances**
```bash
# Check for running instances
ps aux | grep nextcloud-mcp-server
# Kill specific process
kill <pid>
```
---
### Issue: Server starts but can't connect
**Cause:** Server is bound to localhost only, or firewall is blocking connections.
**Solution:**
1. **Check server binding:**
```bash
# Bind to all interfaces to allow network access
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
```
2. **Test connectivity:**
```bash
# Test from same machine
curl http://localhost:8000/health
# Test from network (if using --host 0.0.0.0)
curl http://<server-ip>:8000/health
```
3. **Check firewall:**
```bash
# Linux (ufw)
sudo ufw allow 8000/tcp
# Linux (firewalld)
sudo firewall-cmd --add-port=8000/tcp --permanent
sudo firewall-cmd --reload
```
---
### Issue: Server crashes or restarts frequently
**Cause:** Various issues including memory limits, uncaught exceptions, or OAuth token expiration.
**Solution:**
1. **Check logs with debug level:**
```bash
uv run nextcloud-mcp-server --log-level debug
```
2. **Monitor resource usage:**
```bash
# Check memory and CPU
top -p $(pgrep -f nextcloud-mcp-server)
```
3. **Use process manager for automatic restart:**
```bash
# With systemd (see Running guide for full config)
sudo systemctl restart nextcloud-mcp
# With Docker Compose (includes restart: unless-stopped)
docker-compose up -d
```
4. **Check for OAuth credential expiration** (if using dynamic registration):
- See ["Stored client has expired"](#issue-stored-client-has-expired) above
---
## Connection Issues
### Issue: MCP client can't authenticate
**Cause:** OAuth flow failing or credentials invalid.
**Solution:**
**For OAuth:**
1. Verify OAuth is configured correctly:
```bash
uv run nextcloud-mcp-server --oauth --log-level debug
# Look for "OAuth initialization complete"
```
2. Check that OIDC app is accessible:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
3. Verify MCP_SERVER_URL matches your setup:
```bash
echo $NEXTCLOUD_MCP_SERVER_URL
# Should match the URL clients use to connect
```
**For BasicAuth:**
1. Verify credentials work:
```bash
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \
-H "OCS-APIRequest: true"
```
---
### Issue: Tools return errors or don't work
**Cause:** Missing Nextcloud apps, incorrect permissions, or API issues.
**Solution:**
1. **Verify required Nextcloud apps are installed:**
- Notes: Install "Notes" app
- Calendar: Ensure CalDAV is enabled
- Contacts: Ensure CardDAV is enabled
- Deck: Install "Deck" app
2. **Check user permissions:**
- Ensure the authenticated user has access to the resources
- Check sharing permissions for shared resources
3. **Test API directly:**
```bash
# Test Notes API
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
# Test with OAuth Bearer token
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
4. **Check server logs for specific errors:**
```bash
uv run nextcloud-mcp-server --log-level debug
```
---
## Getting Help
If you continue to experience issues:
### 1. Enable Debug Logging
```bash
uv run nextcloud-mcp-server --log-level debug
```
Review the logs for specific error messages.
### 2. Verify OIDC Configuration (OAuth mode)
```bash
# Check OIDC discovery
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
# Check registration endpoint exists
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
```
### 3. Test Nextcloud API Access
```bash
# Test OCS API (should work with OAuth)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
-H "OCS-APIRequest: true"
# Test app API (may need patch - see oauth2-bearer-token-session-issue.md)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
### 4. Check Versions
```bash
# MCP Server version
uv run nextcloud-mcp-server --version
# Python version
python3 --version
# Nextcloud version (check in admin panel)
```
### 5. Open an Issue
If problems persist, open an issue on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with:
- **Server logs** (with `--log-level debug`)
- **Nextcloud version**
- **OIDC app version** (if using OAuth)
- **Error messages**
- **Steps to reproduce**
- **Environment details** (OS, Python version, Docker vs local)
---
## See Also
- **[OAuth Troubleshooting](oauth-troubleshooting.md)** - Dedicated OAuth troubleshooting guide
- [OAuth Setup Guide](oauth-setup.md) - OAuth configuration
- [OAuth Architecture](oauth-architecture.md) - How OAuth works
- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs
- [Configuration](configuration.md) - Environment variables
- [Running the Server](running.md) - Server options
+62
View File
@@ -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")
```
+20
View File
@@ -1,3 +1,23 @@
# Nextcloud Instance
NEXTCLOUD_HOST=
# ===== AUTHENTICATION MODE =====
# Choose ONE of the following:
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
# - Use only for backward compatibility or if OAuth unavailable
# - If these are set, OAuth mode is disabled
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
+591
View File
@@ -0,0 +1,591 @@
import logging
import os
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
import click
import httpx
import uvicorn
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.routing import Mount
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
from nextcloud_mcp_server.server import (
configure_calendar_tools,
configure_contacts_tools,
configure_cookbook_tools,
configure_deck_tools,
configure_notes_tools,
configure_sharing_tools,
configure_tables_tools,
configure_webdav_tools,
)
logger = logging.getLogger(__name__)
def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
"""
Validate that the OIDC provider properly advertises PKCE support.
According to RFC 8414, if code_challenge_methods_supported is absent,
it means the authorization server does not support PKCE.
MCP clients require PKCE with S256 and will refuse to connect if this
field is missing or doesn't include S256.
"""
code_challenge_methods = discovery.get("code_challenge_methods_supported")
if code_challenge_methods is None:
click.echo("=" * 80, err=True)
click.echo(
"ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement",
err=True,
)
click.echo("=" * 80, err=True)
click.echo(f"Discovery URL: {discovery_url}", err=True)
click.echo("", err=True)
click.echo(
"The OIDC discovery document is missing 'code_challenge_methods_supported'.",
err=True,
)
click.echo(
"According to RFC 8414, this means the server does NOT support PKCE.",
err=True,
)
click.echo("", err=True)
click.echo("⚠️ MCP clients (like Claude Code) WILL REJECT this provider!")
click.echo("", err=True)
click.echo("How to fix:", err=True)
click.echo(
" 1. Ensure PKCE is enabled in Nextcloud OIDC app settings", err=True
)
click.echo(
" 2. Update the OIDC app to advertise PKCE support in discovery", err=True
)
click.echo(" 3. See: RFC 8414 Section 2 (Authorization Server Metadata)")
click.echo("=" * 80, err=True)
click.echo("", err=True)
return
if "S256" not in code_challenge_methods:
click.echo("=" * 80, err=True)
click.echo(
"WARNING: OIDC CONFIGURATION WARNING - S256 Challenge Method Not Advertised",
err=True,
)
click.echo("=" * 80, err=True)
click.echo(f"Discovery URL: {discovery_url}", err=True)
click.echo(f"Advertised methods: {code_challenge_methods}", err=True)
click.echo("", err=True)
click.echo("MCP specification requires S256 code challenge method.", err=True)
click.echo("Some clients may reject this provider.", err=True)
click.echo("=" * 80, err=True)
click.echo("", err=True)
return
click.echo(f"✓ PKCE support validated: {code_challenge_methods}")
@dataclass
class AppContext:
"""Application context for BasicAuth mode."""
client: NextcloudClient
@dataclass
class OAuthAppContext:
"""Application context for OAuth mode."""
nextcloud_host: str
token_verifier: NextcloudTokenVerifier
def is_oauth_mode() -> bool:
"""
Determine if OAuth mode should be used.
OAuth mode is enabled when:
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
- Or explicitly enabled via configuration
Returns:
True if OAuth mode, False if BasicAuth mode
"""
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
# If both username and password are set, use BasicAuth
if username and password:
logger.info(
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
)
return False
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
return True
@asynccontextmanager
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
Manage application lifecycle for BasicAuth mode.
Creates a single Nextcloud client with basic authentication
that is shared across all requests.
"""
logger.info("Starting MCP server in BasicAuth mode")
logger.info("Creating Nextcloud client with BasicAuth")
client = NextcloudClient.from_env()
logger.info("Client initialization complete")
try:
yield AppContext(client=client)
finally:
logger.info("Shutting down BasicAuth mode")
await client.close()
@asynccontextmanager
async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
"""
Manage application lifecycle for OAuth mode.
Initializes OAuth client registration and token verifier.
Does NOT create a Nextcloud client - clients are created per-request.
"""
logger.info("Starting MCP server in OAuth mode")
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST environment variable is required")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OAuth discovery endpoint
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
try:
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info(f"OIDC discovery successful: {discovery_url}")
# Extract endpoints
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
logger.info(f"Userinfo endpoint: {userinfo_uri}")
# Handle client registration
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
if client_id and client_secret:
logger.info("Using pre-configured OAuth client credentials")
elif registration_endpoint:
logger.info("Dynamic client registration available")
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Load or register client
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name="Nextcloud MCP Server",
redirect_uris=redirect_uris,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
else:
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
"2. Dynamic client registration enabled on Nextcloud OIDC app"
)
# Create token verifier
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
)
logger.info("OAuth initialization complete")
try:
yield OAuthAppContext(
nextcloud_host=nextcloud_host, token_verifier=token_verifier
)
finally:
logger.info("Shutting down OAuth mode")
await token_verifier.close()
except Exception as e:
logger.error(f"Failed to initialize OAuth mode: {e}")
raise
async def setup_oauth_config():
"""
Setup OAuth configuration by performing OIDC discovery and client registration.
This is done synchronously before FastMCP initialization because FastMCP
requires token_verifier at construction time.
Returns:
Tuple of (nextcloud_host, token_verifier, auth_settings)
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError(
"NEXTCLOUD_HOST environment variable is required for OAuth mode"
)
nextcloud_host = nextcloud_host.rstrip("/")
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("OIDC discovery successful")
# Validate PKCE support
validate_pkce_support(discovery, discovery_url)
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
# Allow override of public issuer URL for clients
# (useful when MCP server accesses Nextcloud via internal URL
# but needs to advertise a different URL to clients)
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(f"Using public issuer URL for clients: {public_issuer}")
issuer = public_issuer
# Handle client registration
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
if client_id and client_secret:
logger.info("Using pre-configured OAuth client credentials")
elif registration_endpoint:
logger.info("Dynamic client registration available")
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Load or register client
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name="Nextcloud MCP Server",
redirect_uris=redirect_uris,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
else:
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
"2. Dynamic client registration enabled on Nextcloud OIDC app"
)
# Create token verifier
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
)
# Create auth settings
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(mcp_server_url),
required_scopes=["openid", "profile"],
)
logger.info("OAuth configuration complete")
return nextcloud_host, token_verifier, auth_settings
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
setup_logging()
# Determine authentication mode
oauth_enabled = is_oauth_mode()
if oauth_enabled:
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
import asyncio
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_oauth,
token_verifier=token_verifier,
auth=auth_settings,
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
"""Get the Nextcloud Host capabilities"""
ctx: Context = mcp.get_context()
client = get_nextcloud_client(ctx)
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,
"sharing": configure_sharing_tools,
"calendar": configure_calendar_tools,
"contacts": configure_contacts_tools,
"cookbook": configure_cookbook_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_app = mcp.sse_app()
lifespan = None
elif transport in ("http", "streamable-http"):
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=[Mount("/", app=mcp_app)], lifespan=lifespan)
return app
@click.command()
@click.option(
"--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
)
@click.option(
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
)
@click.option(
"--log-level",
"-l",
default="info",
show_default=True,
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
help="Logging level",
)
@click.option(
"--transport",
"-t",
default="sse",
show_default=True,
type=click.Choice(["sse", "streamable-http", "http"]),
help="MCP transport protocol",
)
@click.option(
"--enable-app",
"-e",
multiple=True,
type=click.Choice(
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
),
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
)
@click.option(
"--oauth/--no-oauth",
default=None,
help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
)
@click.option(
"--oauth-client-id",
envvar="NEXTCLOUD_OIDC_CLIENT_ID",
help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
)
@click.option(
"--oauth-client-secret",
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
)
@click.option(
"--oauth-storage-path",
envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE",
default=".nextcloud_oauth_client.json",
show_default=True,
help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)",
)
@click.option(
"--mcp-server-url",
envvar="NEXTCLOUD_MCP_SERVER_URL",
default="http://localhost:8000",
show_default=True,
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
)
def run(
host: str,
port: int,
log_level: str,
transport: str,
enable_app: tuple[str, ...],
oauth: bool | None,
oauth_client_id: str | None,
oauth_client_secret: str | None,
oauth_storage_path: str,
mcp_server_url: str,
):
"""
Run the Nextcloud MCP server.
\b
Authentication Modes:
- BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
- OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
\b
Examples:
# BasicAuth mode (legacy)
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
# OAuth mode with auto-registration
$ nextcloud-mcp-server --oauth
# OAuth mode with pre-configured client
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
"""
# Set OAuth env vars from CLI options if provided
if oauth_client_id:
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
if oauth_client_secret:
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
if oauth_storage_path:
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
if mcp_server_url:
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
# Force OAuth mode if explicitly requested
if oauth is True:
# Clear username/password to force OAuth mode
if "NEXTCLOUD_USERNAME" in os.environ:
click.echo(
"Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
)
del os.environ["NEXTCLOUD_USERNAME"]
if "NEXTCLOUD_PASSWORD" in os.environ:
click.echo(
"Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
)
del os.environ["NEXTCLOUD_PASSWORD"]
# Validate OAuth configuration
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise click.ClickException(
"OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
)
# Check if we have client credentials OR if dynamic registration is possible
has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
"NEXTCLOUD_OIDC_CLIENT_SECRET"
)
if not has_client_creds:
# No client credentials - will attempt dynamic registration
# Show helpful message before server starts
click.echo("", err=True)
click.echo("OAuth Configuration:", err=True)
click.echo(" Mode: Dynamic Client Registration", err=True)
click.echo(" Host: " + nextcloud_host, err=True)
click.echo(
" Storage: "
+ os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
),
err=True,
)
click.echo("", err=True)
click.echo(
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
)
click.echo(" in your Nextcloud OIDC app settings.", err=True)
click.echo("", err=True)
else:
click.echo("", err=True)
click.echo("OAuth Configuration:", err=True)
click.echo(" Mode: Pre-configured Client", err=True)
click.echo(" Host: " + nextcloud_host, err=True)
click.echo(
" Client ID: "
+ os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
+ "...",
err=True,
)
click.echo("", err=True)
elif oauth is False:
# Force BasicAuth mode - verify credentials exist
if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
raise click.ClickException(
"--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
)
enabled_apps = list(enable_app) if enable_app else None
app = get_app(transport=transport, enabled_apps=enabled_apps)
uvicorn.run(
app=app, host=host, port=port, log_level=log_level, log_config=LOGGING_CONFIG
)
if __name__ == "__main__":
run()
+14
View File
@@ -0,0 +1,14 @@
"""OAuth authentication components for Nextcloud MCP server."""
from .bearer_auth import BearerAuth
from .client_registration import load_or_register_client, register_client
from .context_helper import get_client_from_context
from .token_verifier import NextcloudTokenVerifier
__all__ = [
"BearerAuth",
"NextcloudTokenVerifier",
"register_client",
"load_or_register_client",
"get_client_from_context",
]
+34
View File
@@ -0,0 +1,34 @@
"""Bearer token authentication for httpx."""
from httpx import Auth, Request
class BearerAuth(Auth):
"""
Bearer token authentication flow for httpx.
This auth class adds the Authorization: Bearer <token> header
to all outgoing requests.
"""
def __init__(self, token: str):
"""
Initialize bearer authentication.
Args:
token: The bearer token to use for authentication
"""
self.token = token
def auth_flow(self, request: Request):
"""
Add Authorization header to the request.
Args:
request: The outgoing HTTP request
Yields:
The modified request with Authorization header
"""
request.headers["Authorization"] = f"Bearer {self.token}"
yield request
@@ -0,0 +1,261 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class ClientInfo:
"""Client registration information."""
def __init__(
self,
client_id: str,
client_secret: str,
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
):
self.client_id = client_id
self.client_secret = client_secret
self.client_id_issued_at = client_id_issued_at
self.client_secret_expires_at = client_secret_expires_at
self.redirect_uris = redirect_uris
@property
def is_expired(self) -> bool:
"""Check if the client has expired."""
return time.time() >= self.client_secret_expires_at
@property
def expires_soon(self) -> bool:
"""Check if client expires within 5 minutes."""
return time.time() >= (self.client_secret_expires_at - 300)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for storage."""
return {
"client_id": self.client_id,
"client_secret": self.client_secret,
"client_id_issued_at": self.client_id_issued_at,
"client_secret_expires_at": self.client_secret_expires_at,
"redirect_uris": self.redirect_uris,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
"""Create from dictionary."""
return cls(
client_id=data["client_id"],
client_secret=data["client_secret"],
client_id_issued_at=data["client_id_issued_at"],
client_secret_expires_at=data["client_secret_expires_at"],
redirect_uris=data["redirect_uris"],
)
async def register_client(
nextcloud_url: str,
registration_endpoint: str,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
) -> ClientInfo:
"""
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
Args:
nextcloud_url: Base URL of the Nextcloud instance
registration_endpoint: Full URL to the registration endpoint
client_name: Name of the client application
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
scopes: Space-separated list of scopes to request
Returns:
ClientInfo with registration details
Raises:
httpx.HTTPStatusError: If registration fails
ValueError: If response is invalid
"""
if redirect_uris is None:
redirect_uris = ["http://localhost:8000/oauth/callback"]
client_metadata = {
"client_name": client_name,
"redirect_uris": redirect_uris,
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": scopes,
}
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
)
except httpx.HTTPStatusError as e:
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(f"Invalid response from registration endpoint: missing {e}")
raise ValueError(f"Invalid registration response: missing {e}")
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
"""
Load client credentials from storage file.
Args:
storage_path: Path to the JSON file containing client credentials
Returns:
ClientInfo if file exists and is valid, None otherwise
"""
if not storage_path.exists():
logger.debug(f"Client storage file not found: {storage_path}")
return None
try:
with open(storage_path, "r") as f:
data = json.load(f)
client_info = ClientInfo.from_dict(data)
if client_info.is_expired:
logger.warning(
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
)
return None
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
if client_info.expires_soon:
logger.warning("Client expires soon (within 5 minutes)")
return client_info
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error(f"Failed to load client from file: {e}")
return None
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
"""
Save client credentials to storage file.
Args:
client_info: Client information to save
storage_path: Path to save the JSON file
Raises:
OSError: If file cannot be written
"""
try:
# Create directory if it doesn't exist
storage_path.parent.mkdir(parents=True, exist_ok=True)
# Write client info
with open(storage_path, "w") as f:
json.dump(client_info.to_dict(), f, indent=2)
# Set restrictive permissions (owner read/write only)
os.chmod(storage_path, 0o600)
logger.info(f"Saved client credentials to {storage_path}")
except OSError as e:
logger.error(f"Failed to save client credentials: {e}")
raise
async def load_or_register_client(
nextcloud_url: str,
registration_endpoint: str,
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
This function:
1. Checks for existing client credentials in storage
2. Validates the credentials are not expired
3. Registers a new client if needed (no stored credentials or expired)
4. Saves the new client credentials
Args:
nextcloud_url: Base URL of the Nextcloud instance
registration_endpoint: Full URL to the registration endpoint
storage_path: Path to store client credentials
client_name: Name of the client application
redirect_uris: List of redirect URIs
Returns:
ClientInfo with valid credentials
Raises:
httpx.HTTPStatusError: If registration fails
ValueError: If response is invalid
"""
storage_path = Path(storage_path)
# Try to load existing client
client_info = load_client_from_file(storage_path)
if client_info:
return client_info
# Register new client
logger.info("Registering new OAuth client...")
client_info = await register_client(
nextcloud_url=nextcloud_url,
registration_endpoint=registration_endpoint,
client_name=client_name,
redirect_uris=redirect_uris,
)
# Save to storage
save_client_to_file(client_info, storage_path)
return client_info
@@ -0,0 +1,65 @@
"""Helper functions for extracting OAuth context from MCP requests."""
import logging
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from ..client import NextcloudClient
logger = logging.getLogger(__name__)
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
"""
Extract authenticated user context from MCP request and create NextcloudClient.
This function retrieves the OAuth access token from the MCP context,
extracts the username from the token's resource field (where we stored it
during token verification), and creates a NextcloudClient with bearer auth.
Args:
ctx: MCP request context containing session info
base_url: Nextcloud base URL
Returns:
NextcloudClient configured with bearer token auth
Raises:
AttributeError: If context doesn't contain expected OAuth session data
ValueError: If username cannot be extracted from token
"""
try:
# In Starlette with FastMCP OAuth, the authenticated user info is stored in request.user
# The FastMCP auth middleware sets request.user to an AuthenticatedUser object
# which contains the access_token
if hasattr(ctx.request_context.request, "user") and hasattr(
ctx.request_context.request.user, "access_token"
):
access_token: AccessToken = ctx.request_context.request.user.access_token
logger.debug("Retrieved access token from request.user for OAuth request")
else:
logger.error(
"OAuth authentication failed: No access token found in request"
)
raise AttributeError("No access token found in OAuth request context")
# Extract username from resource field (RFC 8707)
# We stored the username here during token verification
username = access_token.resource
if not username:
logger.error("No username found in access token resource field")
raise ValueError("Username not available in OAuth token context")
logger.debug(f"Creating OAuth NextcloudClient for user: {username}")
# Create client with bearer token
return NextcloudClient.from_token(
base_url=base_url, token=access_token.token, username=username
)
except AttributeError as e:
logger.error(f"Failed to extract OAuth context: {e}")
logger.error("This may indicate the server is not running in OAuth mode")
raise
+207
View File
@@ -0,0 +1,207 @@
"""Token verification using Nextcloud OIDC userinfo endpoint."""
import logging
import time
from typing import Any
import httpx
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger(__name__)
class NextcloudTokenVerifier(TokenVerifier):
"""
Validates access tokens using Nextcloud OIDC userinfo endpoint.
This verifier:
1. Calls the userinfo endpoint with the bearer token
2. Caches successful responses to avoid repeated API calls
3. Extracts username from the 'sub' or 'preferred_username' claim
4. Optionally supports JWT validation for performance (future enhancement)
The userinfo endpoint validates the token and returns user claims if valid,
or returns HTTP 400/401 if the token is invalid or expired.
"""
def __init__(
self,
nextcloud_host: str,
userinfo_uri: str,
cache_ttl: int = 3600,
):
"""
Initialize the token verifier.
Args:
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
userinfo_uri: Full URL to the userinfo endpoint
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.userinfo_uri = userinfo_uri
self.cache_ttl = cache_ttl
# Cache: token -> (userinfo, expiry_timestamp)
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
# HTTP client for userinfo requests
self._client = httpx.AsyncClient(timeout=10.0)
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token by calling the userinfo endpoint.
This method:
1. Checks the cache first for recent validations
2. Calls the userinfo endpoint if not cached
3. Returns AccessToken with username stored in metadata
Args:
token: The bearer token to verify
Returns:
AccessToken if valid, None if invalid or expired
"""
# Check cache first
cached = self._get_cached_token(token)
if cached:
logger.debug("Token found in cache")
return cached
# Validate via userinfo endpoint
try:
return await self._verify_via_userinfo(token)
except Exception as e:
logger.warning(f"Token verification failed: {e}")
return None
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
"""
Validate token by calling the userinfo endpoint.
Args:
token: The bearer token to verify
Returns:
AccessToken if valid, None otherwise
"""
try:
response = await self._client.get(
self.userinfo_uri, headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
userinfo = response.json()
logger.debug(
f"Token validated successfully for user: {userinfo.get('sub')}"
)
# Cache the result
expiry = time.time() + self.cache_ttl
self._token_cache[token] = (userinfo, expiry)
# Create AccessToken with username in resource field (workaround for MCP SDK)
username = userinfo.get("sub") or userinfo.get("preferred_username")
if not username:
logger.error("No username found in userinfo response")
return None
return AccessToken(
token=token,
client_id="", # Not available from userinfo
scopes=self._extract_scopes(userinfo),
expires_at=int(expiry),
resource=username, # Store username in resource field (RFC 8707)
)
elif response.status_code in (400, 401, 403):
logger.info(f"Token validation failed: HTTP {response.status_code}")
return None
else:
logger.warning(
f"Unexpected response from userinfo: {response.status_code}"
)
return None
except httpx.TimeoutException:
logger.error("Timeout while validating token via userinfo endpoint")
return None
except httpx.RequestError as e:
logger.error(f"Network error while validating token: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during token validation: {e}")
return None
def _get_cached_token(self, token: str) -> AccessToken | None:
"""
Retrieve a token from cache if not expired.
Args:
token: The bearer token to look up
Returns:
AccessToken if cached and valid, None otherwise
"""
if token not in self._token_cache:
return None
userinfo, expiry = self._token_cache[token]
# Check if expired
if time.time() >= expiry:
logger.debug("Cached token expired, removing from cache")
del self._token_cache[token]
return None
# Return cached AccessToken
username = userinfo.get("sub") or userinfo.get("preferred_username")
return AccessToken(
token=token,
client_id="",
scopes=self._extract_scopes(userinfo),
expires_at=int(expiry),
resource=username,
)
def _extract_scopes(self, userinfo: dict[str, Any]) -> list[str]:
"""
Extract scopes from userinfo response.
Since the userinfo response doesn't include the original scopes,
we infer them from the claims present in the response.
Args:
userinfo: The userinfo response dictionary
Returns:
List of inferred scopes
"""
scopes = ["openid"] # Always present
if "email" in userinfo:
scopes.append("email")
if any(
key in userinfo for key in ["name", "given_name", "family_name", "picture"]
):
scopes.append("profile")
if "roles" in userinfo:
scopes.append("roles")
if "groups" in userinfo:
scopes.append("groups")
return scopes
def clear_cache(self):
"""Clear the token cache."""
self._token_cache.clear()
logger.debug("Token cache cleared")
async def close(self):
"""Cleanup resources."""
await self._client.aclose()
logger.debug("Token verifier closed")
-566
View File
@@ -1,566 +0,0 @@
import os
import time # Import time for sleep
import mimetypes
from io import BytesIO
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, username: str, auth: Auth | None = None):
self.username = username # Store username
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"]
# Pass username to constructor
return cls(base_url=host, username=username, 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,
):
# First, get the current note details to check for category change
old_note = None
try:
if category is not None: # Only fetch if category might change
old_note = self.notes_get_note(note_id=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}")
# Continue with update even if we couldn't fetch current details
old_note = None
# Prepare update body
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,
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}"'},
)
logger.info(
"Update response for note %s: Status %s, Headers %s",
note_id,
response.status_code,
response.headers,
)
response.raise_for_status()
updated_note = response.json()
# Check for category change and clean up 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:
self._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}")
# Continue with update even if cleanup failed
return updated_note
def notes_append_content(self, *, note_id: int, content: str):
"""Append content to an existing note with a standard separator"""
logger.info(f"Appending content to note {note_id}")
# Get current note
current_note = self.notes_get_note(note_id=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 self.notes_update_note(
note_id=note_id,
etag=current_note["etag"],
content=new_content,
title=None, # Keep existing title
category=None # Keep existing category
)
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 = 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 all_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 (optional field)
})
# Sort by score in descending order
search_results.sort(key=lambda x: x["_score"], reverse=True)
# Keep score field for debugging
# for result in search_results:
# if "_score" in result:
# del result["_score"]
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 (optional)
tokens = [token for token in tokens if len(token) > 1]
# Could add stop word removal here
return tokens
def process_note_content(self, note: dict) -> 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
def _cleanup_old_attachment_directory(self, *, note_id: int, old_category: str):
"""
Clean up the attachment directory for a note in its old category location.
Called after a category change to prevent orphaned directories.
"""
# Construct path to old attachment directory
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.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
try:
delete_result = self.delete_webdav_resource(path=old_attachment_dir_path)
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
return delete_result
except Exception as e:
logger.error(f"Error during cleanup of old attachment directory: {e}")
raise e
def delete_webdav_resource(self, *, path: str):
"""Delete a resource (file or directory) via WebDAV DELETE."""
# Ensure path ends with a slash if it's a directory
if not path.endswith('/'):
# This is a heuristic; a more robust solution would check resource type first
# but for the specific case of deleting the attachment directory, this is acceptable.
path_with_slash = f"{path}/"
else:
path_with_slash = path
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
logger.info("Deleting WebDAV resource: %s", 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 = self._client.request("PROPFIND", webdav_path, headers=propfind_headers)
logger.info(f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}")
# If we get here with 2xx, the resource exists
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.info(f"Resource '{webdav_path}' doesn't exist, no deletion needed.")
return {"status_code": 404}
# For other errors, continue with deletion attempt
# Proceed with deletion
response = self._client.delete(webdav_path, headers=headers)
response.raise_for_status() # Raises for 4xx/5xx status codes
logger.info("Successfully deleted WebDAV resource '%s' (Status: %s)", webdav_path, response.status_code)
# DELETE typically returns 204 No Content on success
return {"status_code": response.status_code}
except HTTPStatusError as e:
logger.warning(
"HTTP error deleting WebDAV resource '%s': %s",
webdav_path,
e,
)
# It's expected to get a 404 if the resource doesn't exist, which is fine.
# We only re-raise if it's not a 404.
if e.response.status_code != 404:
raise e
else:
logger.info("Resource '%s' not found, no deletion needed.", webdav_path)
return {"status_code": 404} # Indicate resource was not found
except Exception as e:
logger.warning(
"Unexpected error deleting WebDAV resource '%s': %s",
webdav_path,
e,
)
raise e
def notes_delete_note(self, *, note_id: int):
"""Deletes a note via API and attempts to delete its attachment directory via WebDAV."""
# Fetch note details first to get the category for path construction
try:
note_details = self.notes_get_note(note_id=note_id)
category = note_details.get("category", "")
# Check for other potential categories (if any note was moved between categories)
# We can't reliably detect this without a dedicated tracking mechanism, but we can
# implement a basic check for common category names and empty category
potential_categories = []
if category:
potential_categories.append(category) # Current category first
# Add empty category (uncategorized notes)
if category != "":
potential_categories.append("")
# We could add logic here to check for other common categories if needed
logger.info(f"Note {note_id} has category: '{category}', will check attachment directories in: {potential_categories}")
except HTTPStatusError as e:
# If note doesn't exist (404), we can't delete attachments anyway.
# Re-raise other errors.
if e.response.status_code == 404:
logger.warning(f"Note {note_id} not found when attempting delete. Skipping attachment cleanup.")
# Still raise the 404 as the primary delete operation failed
raise e
else:
logger.error(f"Error fetching note {note_id} details before deleting attachments: {e}")
raise e # Re-raise unexpected errors during fetch
# Proceed with API note deletion
logger.info(f"Deleting note {note_id} via API.")
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
response.raise_for_status() # Raise if API deletion fails
logger.info(f"Note {note_id} deleted successfully via API.")
json_response = response.json() # Usually empty on success
# Now, attempt to delete the associated attachments directory via WebDAV for each potential category
for cat in potential_categories:
cat_path_part = f"{cat}/" if cat else ""
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
logger.info(f"Attempting to delete attachment directory for note {note_id} in category '{cat}' via WebDAV: {attachment_dir_path}")
try:
# delete_webdav_resource expects path relative to user's files dir
delete_result = self.delete_webdav_resource(path=attachment_dir_path)
logger.info(f"WebDAV deletion for category '{cat}' attachment directory: {delete_result}")
except Exception as e:
# Log the error but don't re-raise, as API note deletion itself was successful
# Also, we want to try other potential categories even if one fails
logger.warning(f"Failed during WebDAV deletion for category '{cat}' attachment directory: {e}")
return json_response
# Removed incorrect get_note_attachment method that used Notes API
def _get_webdav_base_path(self) -> str:
"""Helper to get the base WebDAV path for the authenticated user."""
# Use the stored username
return f"/remote.php/dav/files/{self.username}"
# Removed _get_note_attachment_webdav_path helper
def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, category: str | None = None, mime_type: str | None = None):
"""
Add/Update an attachment to a note via WebDAV PUT.
Requires the caller to provide the note's category.
"""
# 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}" # Full path for MKCOL
attachment_path = f"{parent_dir_path}/{filename}" # Full path for PUT
logger.info(f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}")
# Log current auth settings to diagnose the issue
logger.info("WebDAV auth settings - Username: %s, Auth Type: %s",
self.username, type(self._client.auth).__name__)
if not mime_type:
mime_type, _ = mimetypes.guess_type(filename)
if not mime_type:
mime_type = "application/octet-stream" # Default if guessing fails
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
try:
# First check if we can access WebDAV at all with current credentials
# by checking the Notes directory
notes_dir_path = f"{webdav_base}/Notes"
logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path)
# Log details of the auth being used by the client for this specific request
if self._client.auth:
auth_header = self._client.auth.auth_flow(self._client.build_request("GET", notes_dir_path)).__next__().headers.get("Authorization")
logger.info("Authorization header for PROPFIND (Notes dir): %s", auth_header if auth_header else "Not present or generated by auth flow")
else:
logger.info("No httpx.Auth object configured on the client for PROPFIND (Notes dir).")
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers)
notes_dir_response = self._client.request("PROPFIND", notes_dir_path,
headers=propfind_headers)
if notes_dir_response.status_code == 401:
logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.")
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("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code)
notes_dir_response.raise_for_status()
else:
logger.info("Successfully accessed WebDAV Notes directory (Status: %s)",
notes_dir_response.status_code)
# Ensure the parent directory exists using MKCOL
# parent_dir_path is now determined by the helper method
logger.info("Ensuring attachments directory exists: %s", parent_dir_path)
mkcol_headers = {"OCS-APIRequest": "true"}
logger.info("Headers for MKCOL (Attachments dir): %s", mkcol_headers)
mkcol_response = self._client.request("MKCOL", parent_dir_path, headers=mkcol_headers)
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
# We can ignore 405, but raise for other errors
if mkcol_response.status_code not in [201, 405]:
logger.warning(
"Unexpected status code %s when creating attachments directory",
mkcol_response.status_code
)
mkcol_response.raise_for_status()
else:
logger.info("Created/verified directory: %s (Status: %s)",
parent_dir_path, mkcol_response.status_code)
# Proceed with the PUT request
logger.info("Putting attachment file to: %s", attachment_path)
response = self._client.put(
attachment_path,
content=content,
headers=headers
)
response.raise_for_status() # Raises for 4xx/5xx status codes
logger.info("Successfully uploaded attachment '%s' to note %s (Status: %s)", filename, note_id, response.status_code)
# PUT typically returns 201 Created or 204 No Content on success
return {"status_code": response.status_code} # Return status or relevant info
except HTTPStatusError as e:
logger.error(
"HTTP error uploading attachment '%s' to note %s: %s",
filename,
note_id,
e,
)
raise e
except Exception as e:
logger.error(
"Unexpected error uploading attachment '%s' to note %s: %s",
filename,
note_id,
e,
)
raise e
def get_note_attachment(self, *, note_id: int, filename: str, category: str | None = None):
"""
Fetch a specific attachment from a note via WebDAV GET.
Requires the caller to provide the note's category.
"""
# Construct path 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}"
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
logger.info(f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}")
try:
response = self._client.get(attachment_path)
response.raise_for_status()
content = response.content
mime_type = response.headers.get("content-type", "application/octet-stream")
logger.info("Successfully fetched attachment '%s' (%s, %d bytes)", filename, mime_type, len(content))
return content, mime_type
except HTTPStatusError as e:
logger.error(
"HTTP error fetching attachment '%s' for note %s: %s",
filename,
note_id,
e,
)
raise e
except Exception as e:
logger.error(
"Unexpected error fetching attachment '%s' for note %s: %s",
filename,
note_id,
e,
)
raise e
+136
View File
@@ -0,0 +1,136 @@
import logging
import os
from httpx import (
AsyncBaseTransport,
AsyncClient,
AsyncHTTPTransport,
Auth,
BasicAuth,
Request,
Response,
)
from ..controllers.notes_search import NotesSearchController
from .calendar import CalendarClient
from .contacts import ContactsClient
from .cookbook import CookbookClient
from .deck import DeckClient
from .groups import GroupsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
from .users import UsersClient
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(
base_url, username, auth
) # Uses AsyncDavClient internally
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
self.deck = DeckClient(self._client, username)
self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username)
self.sharing = SharingClient(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))
@classmethod
def from_token(cls, base_url: str, token: str, username: str):
"""Create NextcloudClient with OAuth bearer token.
Args:
base_url: Nextcloud base URL
token: OAuth access token
username: Nextcloud username
Returns:
NextcloudClient configured with bearer token authentication
"""
from ..auth import BearerAuth
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
async def capabilities(self):
response = await self._client.get(
"/ocs/v2.php/cloud/capabilities",
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 = self.notes.get_all_notes()
return await 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 and CalDAV client."""
await self._client.aclose()
await self.calendar.close()
+104
View File
@@ -0,0 +1,104 @@
"""Base client for Nextcloud operations with shared authentication."""
import logging
import time
from abc import ABC
from functools import wraps
from httpx import AsyncClient, HTTPStatusError, RequestError, codes
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
+438
View File
@@ -0,0 +1,438 @@
"""CardDAV client for NextCloud contacts operations."""
import logging
import xml.etree.ElementTree as ET
from pythonvCard4.vcard import Contact
from .base import BaseNextcloudClient
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
+250
View File
@@ -0,0 +1,250 @@
"""Client for Nextcloud Cookbook app operations."""
import logging
from typing import Any, Dict, List
from httpx import Timeout
from .base import BaseNextcloudClient
logger = logging.getLogger(__name__)
class CookbookClient(BaseNextcloudClient):
"""Client for Nextcloud Cookbook app operations."""
async def get_version(self) -> Dict[str, Any]:
"""Get Cookbook app and API version."""
response = await self._make_request("GET", "/apps/cookbook/api/version")
return response.json()
async def get_config(self) -> Dict[str, Any]:
"""Get current Cookbook app configuration."""
response = await self._make_request("GET", "/apps/cookbook/api/v1/config")
return response.json()
async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Set Cookbook app configuration.
Args:
config: Configuration dictionary with fields like:
- folder: Recipe folder path
- update_interval: Auto-rescan interval in minutes
- print_image: Whether to print images with recipes
- visibleInfoBlocks: Visible info blocks configuration
Returns:
Response with status message
"""
response = await self._make_request(
"POST", "/apps/cookbook/api/v1/config", json=config
)
return response.json()
async def reindex(self) -> str:
"""Trigger a rescan of all recipes into the caching database.
Returns:
Success message
"""
response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex")
return response.json()
async def list_recipes(self) -> List[Dict[str, Any]]:
"""Get all recipes in the database.
Returns:
List of recipe stubs with basic information
"""
response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes")
return response.json()
async def get_recipe(self, recipe_id: int) -> Dict[str, Any]:
"""Get a single recipe by ID.
Args:
recipe_id: The recipe ID
Returns:
Full recipe data
"""
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
)
return response.json()
async def create_recipe(self, recipe_data: Dict[str, Any]) -> int:
"""Create a new recipe.
Args:
recipe_data: Recipe data following schema.org/Recipe format.
Required: name
Optional: description, ingredients, instructions, etc.
Returns:
ID of the newly created recipe
"""
response = await self._make_request(
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
)
return response.json()
async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int:
"""Update an existing recipe.
Args:
recipe_id: The recipe ID to update
recipe_data: Updated recipe data
Returns:
ID of the updated recipe
"""
response = await self._make_request(
"PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data
)
return response.json()
async def delete_recipe(self, recipe_id: int) -> str:
"""Delete a recipe.
Args:
recipe_id: The recipe ID to delete
Returns:
Success message
"""
response = await self._make_request(
"DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
)
return response.json()
async def import_recipe(self, url: str) -> Dict[str, Any]:
"""Import a recipe from a URL using schema.org metadata.
Args:
url: URL of the recipe to import
Returns:
Full imported recipe data
"""
logger.info(f"Importing recipe from URL: {url}")
response = await self._make_request(
"POST",
"/apps/cookbook/api/v1/import",
json={"url": url},
timeout=Timeout(300.0),
)
return response.json()
async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes:
"""Get the main image of a recipe.
Args:
recipe_id: The recipe ID
size: Image size - "full", "thumb" (250px), or "thumb16" (16px)
Returns:
Image bytes
"""
response = await self._make_request(
"GET",
f"/apps/cookbook/api/v1/recipes/{recipe_id}/image",
params={"size": size},
)
return response.content
async def search_recipes(self, query: str) -> List[Dict[str, Any]]:
"""Search for recipes by keywords, tags, and categories.
Args:
query: Search string (URL-encoded, space/comma separated)
Returns:
List of matching recipe stubs
"""
# URL encode the query
from urllib.parse import quote
encoded_query = quote(query)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
)
return response.json()
async def list_categories(self) -> List[Dict[str, Any]]:
"""Get all known categories.
Note: A category name of '*' indicates recipes with no category.
Returns:
List of categories with recipe counts
"""
response = await self._make_request("GET", "/apps/cookbook/api/v1/categories")
return response.json()
async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]:
"""Get all recipes in a specific category.
Args:
category: Category name (use "_" for recipes with no category)
Returns:
List of recipe stubs in the category
"""
from urllib.parse import quote
encoded_category = quote(category)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
)
return response.json()
async def rename_category(self, old_name: str, new_name: str) -> str:
"""Rename a category.
Args:
old_name: Current category name
new_name: New category name
Returns:
New category name
"""
from urllib.parse import quote
encoded_old_name = quote(old_name)
response = await self._make_request(
"PUT",
f"/apps/cookbook/api/v1/category/{encoded_old_name}",
json={"name": new_name},
)
return response.json()
async def list_keywords(self) -> List[Dict[str, Any]]:
"""Get all known keywords/tags.
Returns:
List of keywords with recipe counts
"""
response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords")
return response.json()
async def get_recipes_with_keywords(
self, keywords: List[str]
) -> List[Dict[str, Any]]:
"""Get all recipes associated with certain keywords.
Args:
keywords: List of keywords to filter by
Returns:
List of recipe stubs matching the keywords
"""
from urllib.parse import quote
# Join keywords with commas
keywords_str = ",".join(keywords)
encoded_keywords = quote(keywords_str)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}"
)
return response.json()
+613
View File
@@ -0,0 +1,613 @@
from typing import Any, Dict, List, Optional
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.deck import (
DeckACL,
DeckAttachment,
DeckBoard,
DeckCard,
DeckComment,
DeckConfig,
DeckLabel,
DeckSession,
DeckStack,
)
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,
) -> DeckACL:
json_data = {
"type": type,
"participant": participant,
"permissionEdit": permission_edit,
"permissionShare": permission_share,
"permissionManage": permission_manage,
}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
json=json_data,
headers=headers,
)
return DeckACL(**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
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
json=json_data,
headers=headers,
)
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
headers=headers,
)
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"},
)
+151
View File
@@ -0,0 +1,151 @@
"""Nextcloud Groups API client."""
import logging
from typing import List
from .base import BaseNextcloudClient, retry_on_429
logger = logging.getLogger(__name__)
class GroupsClient(BaseNextcloudClient):
"""Client for Nextcloud Groups API operations."""
@retry_on_429
async def search_groups(
self,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> List[str]:
"""
Search for groups on the Nextcloud server.
Args:
search: Optional search string to filter groups
limit: Optional limit for number of results
offset: Optional offset for pagination
Returns:
List of group IDs matching the search criteria
"""
params = {}
if search is not None:
params["search"] = search
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
response = await self._client.get(
"/ocs/v2.php/cloud/groups",
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
groups = data["ocs"]["data"].get("groups", [])
return groups
@retry_on_429
async def create_group(self, groupid: str) -> None:
"""
Create a new group.
Args:
groupid: The group ID to create
Raises:
HTTPStatusError: If the request fails (e.g., group already exists)
"""
response = await self._client.post(
"/ocs/v2.php/cloud/groups",
data={"groupid": groupid},
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
logger.info(f"Created group: {groupid}")
@retry_on_429
async def delete_group(self, groupid: str) -> None:
"""
Delete a group.
Args:
groupid: The group ID to delete
Raises:
HTTPStatusError: If the request fails (e.g., group doesn't exist)
"""
response = await self._client.delete(
f"/ocs/v2.php/cloud/groups/{groupid}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
logger.info(f"Deleted group: {groupid}")
@retry_on_429
async def get_group_members(self, groupid: str) -> List[str]:
"""
Get members of a group.
Args:
groupid: The group ID
Returns:
List of usernames in the group
"""
response = await self._client.get(
f"/ocs/v2.php/cloud/groups/{groupid}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
users = data["ocs"]["data"].get("users", [])
return users
@retry_on_429
async def get_group_subadmins(self, groupid: str) -> List[str]:
"""
Get subadmins of a group.
Args:
groupid: The group ID
Returns:
List of usernames who are subadmins of the group
"""
response = await self._client.get(
f"/ocs/v2.php/cloud/groups/{groupid}/subadmins",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
# The API returns data as a list or dict depending on results
subadmins_data = data["ocs"]["data"]
if isinstance(subadmins_data, list):
return subadmins_data
return []
@retry_on_429
async def update_group_displayname(self, groupid: str, displayname: str) -> None:
"""
Update a group's display name.
Args:
groupid: The group ID
displayname: The new display name
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.put(
f"/ocs/v2.php/cloud/groups/{groupid}",
data={"key": "displayname", "value": displayname},
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
logger.info(f"Updated group {groupid} displayname to: {displayname}")
+210
View File
@@ -0,0 +1,210 @@
"""Client for Nextcloud Notes app operations."""
import logging
from typing import Any, AsyncIterator, Dict, 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) -> AsyncIterator[Dict[str, Any]]:
"""Get all notes, yielding them one at a time."""
cursor = ""
while True:
response = await self._make_request(
"GET",
"/apps/notes/api/v1/notes",
params={"chunkSize": 10, "chunkCursor": cursor},
)
for note in response.json():
yield note
if "X-Notes-Chunk-Cursor" not in response.headers:
break
cursor = response.headers["X-Notes-Chunk-Cursor"]
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
)
+208
View File
@@ -0,0 +1,208 @@
"""Nextcloud OCS Sharing API client for file/folder sharing operations."""
import logging
from typing import Any
from .base import BaseNextcloudClient, retry_on_429
logger = logging.getLogger(__name__)
class SharingClient(BaseNextcloudClient):
"""Client for Nextcloud OCS Sharing API operations."""
@retry_on_429
async def create_share(
self,
path: str,
share_with: str,
share_type: int = 0,
permissions: int = 1,
) -> dict[str, Any]:
"""Create a share for a file or folder.
Args:
path: Path to file/folder to share (relative to user's files)
share_with: Username (for user share) or group name (for group share)
share_type: Share type (0=user, 1=group, 3=public link)
permissions: Share permissions:
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common combinations: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
Share data including share ID
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.post(
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
data={
"path": path,
"shareType": share_type,
"shareWith": share_with,
"permissions": permissions,
},
)
response.raise_for_status()
data = response.json()
# OCS API v2 uses HTTP-style status codes (200 for success)
# OCS API v1 used custom codes (100 for success)
ocs_status = data["ocs"]["meta"]["statuscode"]
if ocs_status not in (100, 200):
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
raise RuntimeError(f"OCS API error (code {ocs_status}): {ocs_message}")
share_data = data["ocs"]["data"]
# Handle case where data might be an empty list on error
if not share_data or (isinstance(share_data, list) and len(share_data) == 0):
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
raise RuntimeError(
f"Share creation failed: {ocs_message} (status {ocs_status})"
)
logger.info(
f"Created share {share_data['id']}: {path} -> {share_with} "
f"(type={share_type}, permissions={permissions})"
)
return share_data
@retry_on_429
async def delete_share(self, share_id: int) -> None:
"""Delete a share by its ID.
Args:
share_id: The share ID to delete
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.delete(
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
)
logger.info(f"Deleted share {share_id}")
@retry_on_429
async def get_share(self, share_id: int) -> dict[str, Any]:
"""Get information about a specific share.
Args:
share_id: The share ID
Returns:
Share data
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.get(
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
)
share_data = data["ocs"]["data"]
# The API returns a list with a single share, extract the first element
if isinstance(share_data, list) and len(share_data) > 0:
return share_data[0]
return share_data
@retry_on_429
async def list_shares(
self, path: str | None = None, shared_with_me: bool = False
) -> list[dict[str, Any]]:
"""List shares.
Args:
path: Optional path to filter shares for a specific file/folder
shared_with_me: If True, list shares shared with the current user
Returns:
List of share data
Raises:
HTTPStatusError: If the request fails
"""
params = {}
if path:
params["path"] = path
if shared_with_me:
params["shared_with_me"] = "true"
response = await self._client.get(
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
)
# Handle both single share and list of shares
shares_data = data["ocs"]["data"]
if isinstance(shares_data, dict):
return [shares_data]
return shares_data if shares_data else []
@retry_on_429
async def update_share(
self, share_id: int, permissions: int | None = None
) -> dict[str, Any]:
"""Update a share's permissions.
Args:
share_id: The share ID to update
permissions: New permissions value (see create_share for values)
Returns:
Updated share data
Raises:
HTTPStatusError: If the request fails
"""
data = {}
if permissions is not None:
data["permissions"] = permissions
response = await self._client.put(
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
data=data,
)
response.raise_for_status()
result = response.json()
if result["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {result['ocs']['meta'].get('message', 'Unknown error')}"
)
logger.info(f"Updated share {share_id}")
return result["ocs"]["data"]
+125
View File
@@ -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
+223
View File
@@ -0,0 +1,223 @@
from typing import Dict, List, Optional
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.users import UserDetails
class UsersClient(BaseNextcloudClient):
"""Client for Nextcloud User API operations."""
def _get_user_headers(
self, additional_headers: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""Get standard headers required for User API calls."""
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
if additional_headers:
headers.update(additional_headers)
return headers
async def create_user(
self,
userid: str,
password: Optional[str] = None,
display_name: Optional[str] = None,
email: Optional[str] = None,
groups: Optional[List[str]] = None,
subadmin_groups: Optional[List[str]] = None,
quota: Optional[str] = None,
language: Optional[str] = None,
) -> None:
"""
Create a new user on the Nextcloud server.
"""
data = {"userid": userid}
if password is not None:
data["password"] = password
if display_name is not None:
data["displayName"] = display_name
if email is not None:
data["email"] = email
if groups is not None:
for i, group in enumerate(groups):
data[f"groups[{i}]"] = group
if subadmin_groups is not None:
for i, group in enumerate(subadmin_groups):
data[f"subadmin[{i}]"] = group
if quota is not None:
data["quota"] = quota
if language is not None:
data["language"] = language
headers = self._get_user_headers()
await self._make_request(
"POST", "/ocs/v2.php/cloud/users", data=data, headers=headers
)
async def search_users(
self,
search: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> List[str]:
"""
Retrieves a list of users from the Nextcloud server.
"""
params = {}
if search is not None:
params["search"] = search
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
headers = self._get_user_headers()
response = await self._make_request(
"GET", "/ocs/v2.php/cloud/users", params=params, headers=headers
)
# The v2 API returns JSON with users as a direct list under data.users
data = response.json()["ocs"]["data"]
return data.get("users", [])
async def get_user_details(self, userid: str) -> UserDetails:
"""
Retrieves information about a single user.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
)
return UserDetails(**response.json()["ocs"]["data"])
async def update_user_field(self, userid: str, key: str, value: str) -> None:
"""
Edits attributes related to a user.
"""
data = {"key": key, "value": value}
headers = self._get_user_headers()
await self._make_request(
"PUT", f"/ocs/v2.php/cloud/users/{userid}", data=data, headers=headers
)
async def get_editable_user_fields(self) -> List[str]:
"""
Gets the list of editable data fields for a user.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", "/ocs/v2.php/cloud/user/fields", headers=headers
)
# The v2 API returns data as a direct list
data = response.json()["ocs"]["data"]
return data if isinstance(data, list) else []
async def disable_user(self, userid: str) -> None:
"""
Disables a user on the Nextcloud server.
"""
headers = self._get_user_headers()
await self._make_request(
"PUT", f"/ocs/v2.php/cloud/users/{userid}/disable", headers=headers
)
async def enable_user(self, userid: str) -> None:
"""
Enables a user on the Nextcloud server.
"""
headers = self._get_user_headers()
await self._make_request(
"PUT", f"/ocs/v2.php/cloud/users/{userid}/enable", headers=headers
)
async def delete_user(self, userid: str) -> None:
"""
Deletes a user from the Nextcloud server.
"""
headers = self._get_user_headers()
await self._make_request(
"DELETE", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
)
async def get_user_groups(self, userid: str) -> List[str]:
"""
Retrieves a list of groups the specified user is a member of.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", f"/ocs/v2.php/cloud/users/{userid}/groups", headers=headers
)
# The v2 API returns groups as a direct list under data.groups
data = response.json()["ocs"]["data"]
return data.get("groups", [])
async def add_user_to_group(self, userid: str, groupid: str) -> None:
"""
Adds the specified user to the specified group.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"POST",
f"/ocs/v2.php/cloud/users/{userid}/groups",
data=data,
headers=headers,
)
async def remove_user_from_group(self, userid: str, groupid: str) -> None:
"""
Removes the specified user from the specified group.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"DELETE",
f"/ocs/v2.php/cloud/users/{userid}/groups",
data=data,
headers=headers,
)
async def promote_user_to_subadmin(self, userid: str, groupid: str) -> None:
"""
Makes a user the subadmin of a group.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"POST",
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
data=data,
headers=headers,
)
async def demote_user_from_subadmin(self, userid: str, groupid: str) -> None:
"""
Removes the subadmin rights for the user specified from the group specified.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"DELETE",
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
data=data,
headers=headers,
)
async def get_user_subadmin_groups(self, userid: str) -> List[str]:
"""
Returns the groups in which the user is a subadmin.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", f"/ocs/v2.php/cloud/users/{userid}/subadmins", headers=headers
)
# The v2 API returns data as a direct list
data = response.json()["ocs"]["data"]
return data if isinstance(data, list) else []
async def resend_welcome_email(self, userid: str) -> None:
"""
Triggers the welcome email for this user again.
"""
headers = self._get_user_headers()
await self._make_request(
"POST", f"/ocs/v2.php/cloud/users/{userid}/welcome", headers=headers
)
+948
View File
@@ -0,0 +1,948 @@
"""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
async def search_files(
self,
scope: str = "",
where_conditions: Optional[str] = None,
properties: Optional[List[str]] = None,
order_by: Optional[List[Tuple[str, str]]] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Search for files using WebDAV SEARCH method (RFC 5323).
Args:
scope: Directory path to search in (empty string for user root)
where_conditions: XML string for where clause conditions
properties: List of property names to retrieve (defaults to basic set)
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
limit: Maximum number of results to return
Returns:
List of file/directory dictionaries with requested properties
"""
# Default properties if not specified
if properties is None:
properties = [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
]
# Build the SEARCH request XML
search_body = self._build_search_xml(
scope=scope,
where_conditions=where_conditions,
properties=properties,
order_by=order_by,
limit=limit,
)
# The SEARCH endpoint is at the dav root
search_path = "/remote.php/dav/"
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"}
logger.debug(f"Searching files in scope: {scope}")
try:
response = await self._make_request(
"SEARCH", search_path, content=search_body, headers=headers
)
response.raise_for_status()
# Parse the XML response
results = self._parse_search_response(response.content, scope)
logger.debug(f"Search returned {len(results)} results")
return results
except HTTPStatusError as e:
logger.error(f"HTTP error during search: {e}")
raise e
except Exception as e:
logger.error(f"Unexpected error during search: {e}")
raise e
def _build_search_xml(
self,
scope: str,
where_conditions: Optional[str],
properties: List[str],
order_by: Optional[List[Tuple[str, str]]],
limit: Optional[int],
) -> str:
"""Build the XML body for a SEARCH request."""
# Construct the scope path
username = self.username
scope_path = f"/files/{username}"
if scope:
scope_path = f"{scope_path}/{scope.lstrip('/')}"
# Build property list
prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties])
# Build where clause
where_xml = where_conditions if where_conditions else ""
# Build order by clause
orderby_xml = ""
if order_by:
order_elements = []
for prop, direction in order_by:
prop_element = self._property_to_xml(prop)
dir_element = (
"<d:ascending/>"
if direction.lower() == "ascending"
else "<d:descending/>"
)
order_elements.append(f"<d:order>{prop_element}{dir_element}</d:order>")
orderby_xml = "\n".join(order_elements)
else:
orderby_xml = ""
# Build limit clause
limit_xml = (
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
)
# Construct the full SEARCH XML
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
{prop_xml}
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>{scope_path}</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
{where_xml}
</d:where>
<d:orderby>
{orderby_xml}
</d:orderby>
{limit_xml}
</d:basicsearch>
</d:searchrequest>"""
return search_xml
def _property_to_xml(self, prop: str) -> str:
"""Convert a property name to its XML element."""
# Handle properties with namespace prefixes
if prop.startswith("{"):
# Already a full namespace
namespace_end = prop.index("}")
namespace = prop[1:namespace_end]
local_name = prop[namespace_end + 1 :]
# Map namespace URIs to prefixes
ns_map = {
"DAV:": "d",
"http://owncloud.org/ns": "oc",
"http://nextcloud.org/ns": "nc",
}
prefix = ns_map.get(namespace, "d")
return f"<{prefix}:{local_name}/>"
else:
# Guess namespace based on common properties
if prop in [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
"quota-available-bytes",
"quota-used-bytes",
]:
return f"<d:{prop}/>"
elif prop in [
"fileid",
"size",
"permissions",
"favorite",
"tags",
"owner-id",
"owner-display-name",
"share-types",
"checksums",
"comments-count",
"comments-unread",
]:
return f"<oc:{prop}/>"
else:
# Assume nc namespace for newer properties
return f"<nc:{prop}/>"
def _parse_search_response(
self, xml_content: bytes, scope: str
) -> List[Dict[str, Any]]:
"""Parse the XML response from a SEARCH request."""
root = ET.fromstring(xml_content)
items = []
# Process each response element
responses = root.findall(".//{DAV:}response")
for response_elem in responses:
href = response_elem.find(".//{DAV:}href")
if href is None:
continue
# Extract file/directory path from href
href_text = href.text or ""
# Remove the /remote.php/dav/files/username/ prefix to get relative path
path_parts = href_text.split("/files/")
if len(path_parts) > 1:
# Get the path after username
path_after_user = "/".join(path_parts[1].split("/")[1:])
relative_path = path_after_user.rstrip("/")
else:
relative_path = href_text.rstrip("/").split("/")[-1]
# Get properties
propstat = response_elem.find(".//{DAV:}propstat")
if propstat is None:
continue
prop = propstat.find(".//{DAV:}prop")
if prop is None:
continue
# Build item dictionary
item = {"path": relative_path, "href": href_text}
# Extract all properties
for child in prop:
tag = child.tag
value = child.text
# Remove namespace from tag
if "}" in tag:
tag = tag.split("}", 1)[1]
# Handle special properties
if tag == "resourcetype":
item["is_directory"] = child.find(".//{DAV:}collection") is not None
elif tag == "getcontentlength":
item["size"] = int(value) if value else 0
elif tag == "displayname":
item["name"] = value
elif tag == "getcontenttype":
item["content_type"] = value
elif tag == "getlastmodified":
item["last_modified"] = value
elif tag == "getetag":
item["etag"] = value.strip('"') if value else None
elif tag == "fileid":
item["file_id"] = int(value) if value else None
elif tag == "favorite":
item["is_favorite"] = value == "1"
elif tag == "permissions":
item["permissions"] = value
elif tag == "size":
# oc:size includes folder sizes
item["total_size"] = int(value) if value else 0
else:
# Store other properties as-is
item[tag] = value
items.append(item)
return items
async def find_by_name(
self, pattern: str, scope: str = "", limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Find files by name pattern using LIKE matching.
Args:
pattern: Name pattern to search for (supports % wildcard)
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
List of matching files/directories
Examples:
# Find all .txt files
results = await find_by_name("%.txt")
# Find files starting with "report"
results = await find_by_name("report%")
"""
where_conditions = f"""
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>{pattern}</d:literal>
</d:like>
"""
return await self.search_files(
scope=scope, where_conditions=where_conditions, limit=limit
)
async def find_by_type(
self, mime_type: str, scope: str = "", limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Find files by MIME type.
Args:
mime_type: MIME type to search for (supports % wildcard, e.g., "image/%")
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
List of matching files
Examples:
# Find all images
results = await find_by_type("image/%")
# Find all PDFs
results = await find_by_type("application/pdf")
"""
where_conditions = f"""
<d:like>
<d:prop>
<d:getcontenttype/>
</d:prop>
<d:literal>{mime_type}</d:literal>
</d:like>
"""
return await self.search_files(
scope=scope, where_conditions=where_conditions, limit=limit
)
async def list_favorites(
self, scope: str = "", limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""List all favorite files.
Args:
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
List of favorite files/directories
Examples:
# List all favorites
results = await list_favorites()
# List favorites in a specific folder
results = await list_favorites(scope="Documents")
"""
# Use REPORT method for favorites as it's more efficient
# But we can also use SEARCH as fallback
where_conditions = """
<d:eq>
<d:prop>
<oc:favorite/>
</d:prop>
<d:literal>1</d:literal>
</d:eq>
"""
# Request favorite property
properties = [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
"fileid",
"favorite",
]
return await self.search_files(
scope=scope,
where_conditions=where_conditions,
properties=properties,
limit=limit,
)
+22 -9
View File
@@ -2,36 +2,49 @@ import logging.config
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"default": {
"class": "logging.FileHandler",
"class": "logging.StreamHandler",
"formatter": "http",
# "stream": "ext://sys.stderr"
"filename": "/tmp/nextcloud-mcp-server.log",
"mode": "a",
}
},
},
"formatters": {
"http": {
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
},
"loggers": {
"": {
"handlers": ["default"],
"level": "DEBUG",
"level": "INFO",
},
"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
},
"uvicorn": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
},
}
+51
View File
@@ -0,0 +1,51 @@
"""Helper functions for accessing context in MCP tools."""
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.client import NextcloudClient
def get_client(ctx: Context) -> NextcloudClient:
"""
Get the appropriate Nextcloud client based on authentication mode.
In BasicAuth mode, returns the shared client from lifespan context.
In OAuth mode, creates a new client per-request using the OAuth context.
This function automatically detects the authentication mode by checking
the type of the lifespan context.
Args:
ctx: MCP request context
Returns:
NextcloudClient configured for the current authentication mode
Raises:
AttributeError: If context doesn't contain expected data
Example:
```python
@mcp.tool()
async def my_tool(ctx: Context):
client = get_client(ctx)
return await client.capabilities()
```
"""
lifespan_ctx = ctx.request_context.lifespan_context
# Try BasicAuth mode first (has 'client' attribute)
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
# OAuth mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"):
from nextcloud_mcp_server.auth import get_client_from_context
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
# Unknown context type
raise AttributeError(
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
f"Type: {type(lifespan_ctx)}"
)
@@ -0,0 +1 @@
"""Controllers for utility operations."""
@@ -0,0 +1,102 @@
"""Controller for notes search functionality."""
from typing import Any, AsyncIterable, Dict, List
class NotesSearchController:
"""Handles notes search logic and scoring."""
async def search_notes(
self, notes: AsyncIterable[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
async 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
+142
View File
@@ -0,0 +1,142 @@
"""Pydantic models for structured MCP server responses."""
# Base models
from .base import BaseResponse, IdResponse, StatusResponse
# Calendar models
from .calendar import (
AvailabilitySlot,
BulkOperationResponse,
BulkOperationResult,
Calendar,
CalendarEvent,
CalendarEventSummary,
CreateEventResponse,
CreateMeetingResponse,
DeleteEventResponse,
FindAvailabilityResponse,
ListCalendarsResponse,
ListEventsResponse,
ManageCalendarResponse,
UpcomingEventsResponse,
UpdateEventResponse,
)
# Contacts models
from .contacts import (
AddressBook,
Contact,
ContactField,
CreateAddressBookResponse,
CreateContactResponse,
DeleteAddressBookResponse,
DeleteContactResponse,
ListAddressBooksResponse,
ListContactsResponse,
UpdateContactResponse,
)
# Notes models
from .notes import (
AppendContentResponse,
CreateNoteResponse,
DeleteNoteResponse,
Note,
NoteSearchResult,
NotesSettings,
SearchNotesResponse,
UpdateNoteResponse,
)
# Tables models
from .tables import (
CreateRowResponse,
DeleteRowResponse,
GetSchemaResponse,
ListTablesResponse,
ReadTableResponse,
Table,
TableColumn,
TableRow,
TableSchema,
TableView,
UpdateRowResponse,
)
# WebDAV models
from .webdav import (
CopyResourceResponse,
CreateDirectoryResponse,
DeleteResourceResponse,
DirectoryListing,
FileInfo,
MoveResourceResponse,
ReadFileResponse,
SearchFilesResponse,
WriteFileResponse,
)
__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",
"MoveResourceResponse",
"CopyResourceResponse",
"SearchFilesResponse",
]
+48
View File
@@ -0,0 +1,48 @@
"""Base Pydantic models for common response patterns."""
from datetime import datetime, timezone
from typing import Optional, Union
from pydantic import BaseModel, Field, field_serializer
def _utc_now() -> datetime:
"""Generate UTC timestamp for responses."""
return datetime.now(timezone.utc)
class BaseResponse(BaseModel):
"""Base response model for all MCP tool responses."""
success: bool = Field(
default=True, description="Whether the operation was successful"
)
timestamp: datetime = Field(
default_factory=_utc_now, description="Response timestamp"
)
@field_serializer("timestamp")
def serialize_timestamp(self, timestamp: datetime) -> str:
"""Serialize timestamp to RFC3339 format for MCP compliance."""
if timestamp.tzinfo is None:
# If somehow we get a naive datetime, assume UTC
timestamp = timestamp.replace(tzinfo=timezone.utc)
# Use isoformat() which produces RFC3339 compliant format
# For UTC times, replace '+00:00' with 'Z' as preferred by many systems
iso_string = timestamp.isoformat()
if iso_string.endswith("+00:00"):
return iso_string[:-6] + "Z"
return iso_string
class IdResponse(BaseResponse):
"""Response model for operations that return a new ID."""
id: Union[int, str] = Field(description="ID of the created or affected resource")
class StatusResponse(BaseResponse):
"""Response model for operations that return just a status."""
status_code: Optional[int] = Field(None, description="HTTP status code")
message: Optional[str] = Field(None, description="Status message")
+250
View File
@@ -0,0 +1,250 @@
"""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")
# ============= Todo/Task Models =============
class Todo(BaseModel):
"""Model for a CalDAV todo/task (VTODO)."""
uid: str = Field(description="Todo UID")
summary: str = Field(description="Todo summary/title")
description: str = Field(default="", description="Todo description")
status: str = Field(
default="NEEDS-ACTION",
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
)
priority: int = Field(
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
)
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
completed: Optional[str] = Field(
None, description="Completion timestamp (ISO format)"
)
categories: str = Field(default="", description="Comma-separated categories")
href: str = Field(default="", description="CalDAV href")
etag: str = Field(default="", description="ETag for versioning")
calendar_name: Optional[str] = Field(
None, description="Calendar containing this todo"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this todo"
)
class ListTodosResponse(BaseResponse):
"""Response model for listing todos."""
todos: List[Todo] = Field(description="List of todos/tasks")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
total_count: int = Field(description="Total number of todos found")
class CreateTodoResponse(BaseResponse):
"""Response model for todo creation."""
todo: Todo = Field(description="The created todo")
calendar_name: str = Field(
description="Name of the calendar the todo was created in"
)
class UpdateTodoResponse(BaseResponse):
"""Response model for todo updates."""
todo: Todo = Field(description="The updated todo")
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
class DeleteTodoResponse(StatusResponse):
"""Response model for todo deletion."""
deleted_uid: str = Field(description="UID of the deleted todo")
calendar_name: str = Field(
description="Name of the calendar the todo was deleted from"
)
+130
View File
@@ -0,0 +1,130 @@
"""Pydantic models for Contacts app responses."""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, StatusResponse
class AddressBook(BaseModel):
"""Model for a Nextcloud address book."""
uri: str = Field(description="Address book URI")
displayname: str = Field(description="Address book display name")
description: Optional[str] = Field(None, description="Address book description")
ctag: Optional[str] = Field(
None, description="Address book tag for synchronization"
)
class ContactField(BaseModel):
"""Model for a contact field (email, phone, etc.)."""
type: str = Field(description="Field type (e.g., 'email', 'phone', 'address')")
value: str = Field(description="Field value")
label: Optional[str] = Field(None, description="Field label (e.g., 'work', 'home')")
preferred: bool = Field(
default=False, description="Whether this is the preferred field of this type"
)
class Contact(BaseModel):
"""Model for a Nextcloud contact."""
uid: str = Field(description="Contact UID")
fn: str = Field(description="Full name (formatted name)")
given_name: Optional[str] = Field(None, description="Given name")
family_name: Optional[str] = Field(None, description="Family name")
organization: Optional[str] = Field(None, description="Organization")
title: Optional[str] = Field(None, description="Job title")
emails: List[ContactField] = Field(
default_factory=list, description="Email addresses"
)
phones: List[ContactField] = Field(
default_factory=list, description="Phone numbers"
)
addresses: List[ContactField] = Field(default_factory=list, description="Addresses")
urls: List[ContactField] = Field(default_factory=list, description="URLs")
note: Optional[str] = Field(None, description="Notes")
photo: Optional[str] = Field(None, description="Photo URL or base64 data")
birthday: Optional[str] = Field(None, description="Birthday (ISO date format)")
categories: List[str] = Field(
default_factory=list, description="Contact categories"
)
custom_fields: Dict[str, Any] = Field(
default_factory=dict, description="Custom fields"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
@property
def primary_email(self) -> Optional[str]:
"""Get the primary email address."""
if not self.emails:
return None
# Return preferred email if available, otherwise first email
preferred = next(
(email.value for email in self.emails if email.preferred), None
)
return preferred or self.emails[0].value
@property
def primary_phone(self) -> Optional[str]:
"""Get the primary phone number."""
if not self.phones:
return None
# Return preferred phone if available, otherwise first phone
preferred = next(
(phone.value for phone in self.phones if phone.preferred), None
)
return preferred or self.phones[0].value
class ListAddressBooksResponse(BaseResponse):
"""Response model for listing address books."""
addressbooks: List[AddressBook] = Field(
description="List of available address books"
)
total_count: int = Field(description="Total number of address books")
class ListContactsResponse(BaseResponse):
"""Response model for listing contacts."""
contacts: List[Contact] = Field(description="List of contacts")
addressbook: str = Field(description="Address book name")
total_count: int = Field(description="Total number of contacts")
class CreateContactResponse(BaseResponse):
"""Response model for contact creation."""
contact: Contact = Field(description="The created contact")
addressbook: str = Field(description="Address book the contact was created in")
class UpdateContactResponse(BaseResponse):
"""Response model for contact updates."""
contact: Contact = Field(description="The updated contact")
addressbook: str = Field(description="Address book the contact belongs to")
class DeleteContactResponse(StatusResponse):
"""Response model for contact deletion."""
deleted_uid: str = Field(description="UID of the deleted contact")
addressbook: str = Field(description="Address book the contact was deleted from")
class CreateAddressBookResponse(BaseResponse):
"""Response model for address book creation."""
addressbook: AddressBook = Field(description="The created address book")
class DeleteAddressBookResponse(StatusResponse):
"""Response model for address book deletion."""
deleted_name: str = Field(description="Name of the deleted address book")
+220
View File
@@ -0,0 +1,220 @@
"""Pydantic models for Cookbook app responses."""
from typing import List, Optional, Union
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class Nutrition(BaseModel):
"""Nutrition information following schema.org/NutritionInformation."""
type: str = Field(
default="NutritionInformation",
alias="@type",
description="Schema.org object type",
)
calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')")
carbohydrateContent: Optional[str] = Field(
None, description="Carbohydrates (e.g., '300 g')"
)
cholesterolContent: Optional[str] = Field(
None, description="Cholesterol (e.g., '10 g')"
)
fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')")
fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')")
proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')")
saturatedFatContent: Optional[str] = Field(
None, description="Saturated fat (e.g., '5 g')"
)
servingSize: Optional[str] = Field(
None, description="Serving size description (e.g., 'One plate')"
)
sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')")
sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')")
transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')")
unsaturatedFatContent: Optional[str] = Field(
None, description="Unsaturated fat (e.g., '40 g')"
)
class Config:
populate_by_name = True
class RecipeStub(BaseModel):
"""Stub of a recipe with basic information."""
id: str = Field(description="Recipe ID as string")
recipe_id: int = Field(description="Recipe ID as integer (deprecated)")
name: str = Field(description="Recipe name")
keywords: Optional[str] = Field(default="", description="Comma-separated keywords")
dateCreated: str = Field(description="Creation date (ISO8601)")
dateModified: Optional[str] = Field(
None, description="Last modified date (ISO8601)"
)
imageUrl: str = Field(default="", description="URL of the recipe image")
imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image")
class Recipe(BaseModel):
"""Full recipe following schema.org/Recipe specification."""
type: str = Field(default="Recipe", alias="@type", description="Schema.org type")
id: Optional[str] = Field(None, description="Recipe ID")
name: str = Field(description="Recipe name")
description: str = Field(default="", description="Recipe description")
url: str = Field(default="", description="Original recipe URL")
image: str = Field(default="", description="URL of original recipe image")
imageUrl: Optional[str] = Field(
None, description="URL of the recipe image in Nextcloud"
)
imagePlaceholderUrl: Optional[str] = Field(
None, description="URL of placeholder image"
)
keywords: str = Field(default="", description="Comma-separated keywords")
dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)")
dateModified: Optional[str] = Field(
None, description="Last modified date (ISO8601)"
)
prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)")
cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)")
totalTime: Optional[str] = Field(None, description="Total time (ISO8601)")
recipeYield: Union[int, str] = Field(default=1, description="Number of servings")
recipeCategory: str = Field(default="", description="Recipe category")
tool: List[str] = Field(default_factory=list, description="Required tools")
recipeIngredient: List[str] = Field(
default_factory=list, description="List of ingredients"
)
recipeInstructions: List[str] = Field(
default_factory=list, description="Cooking instructions"
)
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
class Config:
populate_by_name = True
extra = "allow" # Allow additional schema.org fields
class Category(BaseModel):
"""A recipe category."""
name: str = Field(description="Category name")
recipe_count: int = Field(description="Number of recipes in category")
class Keyword(BaseModel):
"""A recipe keyword/tag."""
name: str = Field(description="Keyword name")
recipe_count: int = Field(description="Number of recipes with this keyword")
class VisibleInfoBlocks(BaseModel):
"""Configuration for visible information blocks in the UI."""
preparation_time: Optional[bool] = Field(
None, alias="preparation-time", description="Show preparation time"
)
cooking_time: Optional[bool] = Field(
None, alias="cooking-time", description="Show cooking time"
)
total_time: Optional[bool] = Field(
None, alias="total-time", description="Show total time"
)
nutrition_information: Optional[bool] = Field(
None, alias="nutrition-information", description="Show nutrition info"
)
tools: Optional[bool] = Field(None, description="Show tools list")
class Config:
populate_by_name = True
class CookbookConfig(BaseModel):
"""Cookbook app configuration."""
folder: Optional[str] = Field(None, description="Recipe folder path")
update_interval: Optional[int] = Field(
None, description="Auto-rescan interval in minutes"
)
print_image: Optional[bool] = Field(None, description="Print images with recipes")
visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field(
None, description="Visible info blocks configuration"
)
class APIVersion(BaseModel):
"""API version information."""
epoch: int = Field(description="API epoch")
major: int = Field(description="Major version")
minor: int = Field(description="Minor version")
class Version(BaseModel):
"""Version information for Cookbook app and API."""
cookbook_version: List[int] = Field(description="Cookbook app version")
api_version: APIVersion = Field(description="API version")
# Response models for MCP tools
class ImportRecipeResponse(BaseResponse):
"""Response model for recipe import."""
recipe: Recipe = Field(description="The imported recipe")
recipe_id: str = Field(description="ID of the imported recipe")
class CreateRecipeResponse(IdResponse):
"""Response model for recipe creation."""
pass
class UpdateRecipeResponse(IdResponse):
"""Response model for recipe update."""
pass
class DeleteRecipeResponse(StatusResponse):
"""Response model for recipe deletion."""
deleted_id: int = Field(description="ID of deleted recipe")
class ListRecipesResponse(BaseResponse):
"""Response model for listing recipes."""
recipes: List[RecipeStub] = Field(description="List of recipe stubs")
total_count: int = Field(description="Total number of recipes")
class SearchRecipesResponse(BaseResponse):
"""Response model for recipe search."""
recipes: List[RecipeStub] = Field(description="Matching recipes")
query: str = Field(description="Search query used")
total_found: int = Field(description="Number of recipes found")
class ListCategoriesResponse(BaseResponse):
"""Response model for listing categories."""
categories: List[Category] = Field(description="List of categories")
class ListKeywordsResponse(BaseResponse):
"""Response model for listing keywords."""
keywords: List[Keyword] = Field(description="List of keywords")
class ReindexResponse(StatusResponse):
"""Response model for reindex operation."""
pass
+268
View File
@@ -0,0 +1,268 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field, field_validator
from .base import BaseResponse, StatusResponse
class DeckUser(BaseModel):
primaryKey: str
uid: str
displayname: str
class DeckPermissions(BaseModel):
PERMISSION_READ: bool
PERMISSION_EDIT: bool
PERMISSION_MANAGE: bool
PERMISSION_SHARE: bool
class DeckLabel(BaseModel):
id: int
title: str
color: str
boardId: Optional[int] = None
cardId: Optional[int] = None
class DeckACL(BaseModel):
id: int
participant: DeckUser
type: int
boardId: int
permissionEdit: bool
permissionShare: bool
permissionManage: bool
owner: bool
class DeckBoardSettings(BaseModel):
calendar: bool
cardDetailsInModal: Optional[bool] = Field(default=None, alias="cardDetailsInModal")
cardIdBadge: Optional[bool] = Field(default=None, alias="cardIdBadge")
groupLimit: Optional[List[Dict[str, str]]] = Field(default=None, alias="groupLimit")
notify_due: Optional[str] = Field(default=None, alias="notify-due")
class DeckBoard(BaseModel):
id: int
title: str
owner: DeckUser
color: str
archived: bool
labels: List[DeckLabel]
acl: List[DeckACL]
permissions: DeckPermissions
users: List[DeckUser]
deletedAt: int
lastModified: Optional[int] = None
settings: Optional[DeckBoardSettings] = None
etag: Optional[str] = Field(default=None, alias="ETag")
@field_validator("settings", mode="before")
@classmethod
def validate_settings(cls, v):
# Handle case where API returns empty array instead of dict/null
if isinstance(v, list) and len(v) == 0:
return None
return v
class DeckAssignedUser(BaseModel):
id: int
participant: DeckUser
cardId: int
type: int
class DeckCard(BaseModel):
id: int
title: str
stackId: int
type: str
order: int
archived: bool
owner: Union[str, DeckUser] # Can be either string or user object
description: Optional[str] = None
duedate: Optional[datetime] = None
done: Optional[datetime] = None
lastModified: Optional[int] = None
createdAt: Optional[int] = None
labels: Optional[List[DeckLabel]] = None
assignedUsers: Optional[List[Union[DeckUser, DeckAssignedUser]]] = None
attachments: Optional[List[Any]] = None # Define a proper Attachment model later
attachmentCount: Optional[int] = None
deletedAt: Optional[int] = None
commentsUnread: Optional[int] = None
overdue: Optional[int] = None
etag: Optional[str] = Field(default=None, alias="ETag")
@field_validator("owner", mode="before")
@classmethod
def validate_owner(cls, v):
# Handle case where API returns user object instead of string
if isinstance(v, dict):
return v.get("uid", v.get("primaryKey", str(v)))
return v
@field_validator("assignedUsers", mode="before")
@classmethod
def validate_assigned_users(cls, v):
# Handle different formats of assigned users from the API
if not v:
return v
validated_users = []
for user in v:
if isinstance(user, dict):
# Check if it's an assignment object with participant
if "participant" in user:
validated_users.append(user)
# Check if it's a direct user object
elif "uid" in user or "primaryKey" in user:
validated_users.append(user)
else:
validated_users.append(user)
return validated_users
class DeckStack(BaseModel):
id: int
title: str
boardId: int
order: int
deletedAt: int
lastModified: Optional[int] = None
cards: Optional[List[DeckCard]] = None
etag: Optional[str] = Field(default=None, alias="ETag")
class DeckAttachmentExtendedData(BaseModel):
filesize: int
mimetype: str
info: Dict[str, str]
class DeckAttachment(BaseModel):
id: int
cardId: int
type: str
data: str
lastModified: int
createdAt: int
createdBy: str
deletedAt: int
extendedData: DeckAttachmentExtendedData
class DeckComment(BaseModel):
id: int
objectId: int
message: str
actorId: str
actorType: str
actorDisplayName: str
creationDateTime: datetime
mentions: List[Dict[str, str]]
replyTo: Optional[Any] = None # Self-referencing, handle later if needed
class DeckSession(BaseModel):
token: str
class DeckConfig(BaseModel):
calendar: bool
cardDetailsInModal: bool
cardIdBadge: bool
groupLimit: Optional[List[Dict[str, str]]] = None
# Response Models for MCP Tools
class ListBoardsResponse(BaseResponse):
"""Response model for listing deck boards."""
boards: List[DeckBoard] = Field(description="List of deck boards")
total: int = Field(description="Total number of boards")
class CreateBoardResponse(BaseResponse):
"""Response model for board creation."""
id: int = Field(description="The created board ID")
title: str = Field(description="The created board title")
color: str = Field(description="The created board color")
class BoardOperationResponse(StatusResponse):
"""Response model for board operations like update/delete."""
board_id: int = Field(description="ID of the affected board")
# Stack Response Models
class ListStacksResponse(BaseResponse):
"""Response model for listing deck stacks."""
stacks: List[DeckStack] = Field(description="List of deck stacks")
total: int = Field(description="Total number of stacks")
class CreateStackResponse(BaseResponse):
"""Response model for stack creation."""
id: int = Field(description="The created stack ID")
title: str = Field(description="The created stack title")
order: int = Field(description="The created stack order")
class StackOperationResponse(StatusResponse):
"""Response model for stack operations like update/delete."""
stack_id: int = Field(description="ID of the affected stack")
board_id: int = Field(description="ID of the board containing the stack")
# Card Response Models
class CreateCardResponse(BaseResponse):
"""Response model for card creation."""
id: int = Field(description="The created card ID")
title: str = Field(description="The created card title")
description: Optional[str] = Field(description="The created card description")
stackId: int = Field(description="The stack ID the card belongs to")
class CardOperationResponse(StatusResponse):
"""Response model for card operations like update/delete."""
card_id: int = Field(description="ID of the affected card")
stack_id: int = Field(description="ID of the stack containing the card")
board_id: int = Field(description="ID of the board containing the card")
# Label Response Models
class CreateLabelResponse(BaseResponse):
"""Response model for label creation."""
id: int = Field(description="The created label ID")
title: str = Field(description="The created label title")
color: str = Field(description="The created label color")
class LabelOperationResponse(StatusResponse):
"""Response model for label operations like update/delete."""
label_id: int = Field(description="ID of the affected label")
board_id: int = Field(description="ID of the board containing the label")
+85
View File
@@ -0,0 +1,85 @@
"""Pydantic models for Notes app responses."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class Note(BaseModel):
"""Model for a Nextcloud note."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
content: str = Field(description="Note content in markdown")
category: str = Field(default="", description="Note category")
modified: int = Field(description="Unix timestamp of last modification")
favorite: bool = Field(
default=False, description="Whether note is marked as favorite"
)
etag: str = Field(description="ETag for versioning")
readonly: bool = Field(default=False, description="Whether note is read-only")
@property
def modified_datetime(self) -> datetime:
"""Convert Unix timestamp to datetime."""
return datetime.fromtimestamp(self.modified)
class NoteSearchResult(BaseModel):
"""Model for note search results (limited fields)."""
id: int = Field(description="Note ID")
title: str = Field(description="Note title")
category: str = Field(default="", description="Note category")
score: Optional[float] = Field(None, description="Search relevance score")
class NotesSettings(BaseModel):
"""Model for Notes app settings."""
notesPath: str = Field(description="Path to notes directory")
fileSuffix: str = Field(description="File suffix for notes")
noteMode: str = Field(description="Note mode setting")
class CreateNoteResponse(IdResponse):
"""Response model for note creation."""
title: str = Field(description="The created note title")
category: str = Field(description="The created note category")
etag: str = Field(description="Current ETag for the created note")
class UpdateNoteResponse(BaseResponse):
"""Response model for note updates."""
id: int = Field(description="The updated note ID")
title: str = Field(description="The updated note title")
category: str = Field(description="The updated note category")
etag: str = Field(description="Current ETag for the updated note")
class DeleteNoteResponse(StatusResponse):
"""Response model for note deletion."""
deleted_id: int = Field(description="ID of the deleted note")
class AppendContentResponse(BaseResponse):
"""Response model for appending content to a note."""
id: int = Field(description="The updated note ID")
title: str = Field(description="The updated note title")
category: str = Field(description="The updated note category")
etag: str = Field(description="Current ETag for the updated note")
class SearchNotesResponse(BaseResponse):
"""Response model for note search."""
results: List[NoteSearchResult] = Field(description="Search results")
query: str = Field(description="The search query used")
total_found: int = Field(description="Total number of notes found")
+142
View File
@@ -0,0 +1,142 @@
"""Pydantic models for Tables app responses."""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from .base import BaseResponse, IdResponse, StatusResponse
class TableColumn(BaseModel):
"""Model for a table column definition."""
id: int = Field(description="Column ID")
title: str = Field(description="Column title")
type: str = Field(description="Column type (text, number, datetime, etc.)")
subtype: Optional[str] = Field(None, description="Column subtype")
mandatory: bool = Field(default=False, description="Whether column is mandatory")
description: Optional[str] = Field(None, description="Column description")
text_default: Optional[str] = Field(None, description="Default text value")
text_allowed_pattern: Optional[str] = Field(
None, description="Allowed text pattern"
)
text_max_length: Optional[int] = Field(None, description="Maximum text length")
number_default: Optional[float] = Field(None, description="Default number value")
number_min: Optional[float] = Field(None, description="Minimum number value")
number_max: Optional[float] = Field(None, description="Maximum number value")
number_decimals: Optional[int] = Field(None, description="Number of decimal places")
datetime_default: Optional[str] = Field(None, description="Default datetime value")
selection_options: List[str] = Field(
default_factory=list, description="Selection options"
)
selection_default: Optional[str] = Field(
None, description="Default selection value"
)
class TableRow(BaseModel):
"""Model for a table row."""
id: int = Field(description="Row ID")
created_by: Optional[str] = Field(None, description="User who created the row")
created_at: Optional[str] = Field(None, description="Row creation timestamp")
last_edit_by: Optional[str] = Field(
None, description="User who last edited the row"
)
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
data: Dict[int, Any] = Field(description="Row data keyed by column ID")
class TableView(BaseModel):
"""Model for a table view."""
id: int = Field(description="View ID")
title: str = Field(description="View title")
emoji: Optional[str] = Field(None, description="View emoji")
description: Optional[str] = Field(None, description="View description")
columns: List[int] = Field(
default_factory=list, description="List of column IDs in this view"
)
sort: List[Dict[str, Any]] = Field(
default_factory=list, description="Sort configuration"
)
filter: List[Dict[str, Any]] = Field(
default_factory=list, description="Filter configuration"
)
class Table(BaseModel):
"""Model for a Nextcloud table."""
id: int = Field(description="Table ID")
title: str = Field(description="Table title")
emoji: Optional[str] = Field(None, description="Table emoji")
ownership: str = Field(description="Table ownership")
owner_display_name: str = Field(description="Display name of table owner")
created_by: Optional[str] = Field(None, description="User who created the table")
created_at: Optional[str] = Field(None, description="Table creation timestamp")
last_edit_by: Optional[str] = Field(
None, description="User who last edited the table"
)
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
row_count: int = Field(default=0, description="Number of rows in the table")
has_shares: bool = Field(default=False, description="Whether table is shared")
archived: bool = Field(default=False, description="Whether table is archived")
is_shared: bool = Field(
default=False, description="Whether table is shared with current user"
)
on_share_permissions: Optional[Dict[str, Any]] = Field(
None, description="Share permissions"
)
class TableSchema(BaseModel):
"""Model for complete table schema including columns and views."""
table: Table = Field(description="Table information")
columns: List[TableColumn] = Field(description="Table columns")
views: List[TableView] = Field(description="Table views")
class ListTablesResponse(BaseResponse):
"""Response model for listing tables."""
tables: List[Table] = Field(description="List of available tables")
total_count: int = Field(description="Total number of tables")
class GetSchemaResponse(BaseResponse):
"""Response model for getting table schema."""
table_schema: TableSchema = Field(description="Table schema information")
class ReadTableResponse(BaseResponse):
"""Response model for reading table rows."""
rows: List[TableRow] = Field(description="Table rows")
table_id: int = Field(description="Table ID")
total_count: Optional[int] = Field(
None, description="Total number of rows (if known)"
)
offset: Optional[int] = Field(None, description="Offset used for pagination")
limit: Optional[int] = Field(None, description="Limit used for pagination")
class CreateRowResponse(IdResponse):
"""Response model for row creation."""
row: TableRow = Field(description="The created row")
table_id: int = Field(description="Table ID the row was created in")
class UpdateRowResponse(BaseResponse):
"""Response model for row updates."""
row: TableRow = Field(description="The updated row")
class DeleteRowResponse(StatusResponse):
"""Response model for row deletion."""
deleted_id: int = Field(description="ID of the deleted row")
+41
View File
@@ -0,0 +1,41 @@
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
class User(BaseModel):
"""Model for creating a new user."""
userid: str
password: Optional[str] = None
displayName: Optional[str] = None
email: Optional[str] = None
groups: Optional[List[str]] = Field(default_factory=list)
subadmin: Optional[List[str]] = Field(default_factory=list)
quota: Optional[str] = None
language: Optional[str] = None
class UserDetails(BaseModel):
"""Model for retrieving detailed user information."""
model_config = ConfigDict(populate_by_name=True)
enabled: bool
id: str
quota: Union[str, Dict[str, Any]] # Can be string or quota object
email: Optional[str] = None # Can be null
displayname: str = Field(
alias="display-name"
) # Handle both displayname and display-name
phone: Optional[str] = None
address: Optional[str] = None
website: Optional[str] = None
twitter: Optional[str] = None
groups: Optional[List[str]] = Field(default_factory=list)
class Group(BaseModel):
"""Model for a user group."""
id: str
+121
View File
@@ -0,0 +1,121 @@
"""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")
file_id: Optional[int] = Field(None, description="Nextcloud file ID")
is_favorite: Optional[bool] = Field(None, description="Whether file is favorited")
@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"
)
class SearchFilesResponse(BaseResponse):
"""Response model for WebDAV search operations."""
results: List[FileInfo] = Field(description="Search results")
total_found: int = Field(description="Total number of files found")
scope: str = Field(description="The scope/path that was searched")
filters_applied: Optional[dict] = Field(
None, description="Filters that were applied to the search"
)
-151
View File
@@ -1,151 +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
import asyncio # Import asyncio
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()
# Add a small delay to allow client initialization to complete
logger.info("Waiting 2 seconds for client initialization...")
logger.info("Client initialization wait complete.")
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.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_append_content(note_id: int, content: str, ctx: Context):
"""Append content to an existing note with a clear separator"""
logger.info("Appending content to note %s", note_id)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return client.notes_append_content(note_id=note_id, content=content)
@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)
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
def nc_notes_get_attachment(note_id: int, attachment_filename: str):
"""Get a specific attachment from a note"""
ctx = 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 = client.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
}
]
}
def run():
mcp.run()
if __name__ == "__main__":
logger.info("Starting now")
mcp.run()
+19
View File
@@ -0,0 +1,19 @@
from .calendar import configure_calendar_tools
from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools
from .notes import configure_notes_tools
from .sharing import configure_sharing_tools
from .tables import configure_tables_tools
from .webdav import configure_webdav_tools
__all__ = [
"configure_calendar_tools",
"configure_contacts_tools",
"configure_cookbook_tools",
"configure_deck_tools",
"configure_notes_tools",
"configure_sharing_tools",
"configure_tables_tools",
"configure_webdav_tools",
]
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.context import get_client
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 = get_client(ctx)
return await client.contacts.list_addressbooks()
@mcp.tool()
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
client = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
return await client.contacts.update_contact(
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
)
+594
View File
@@ -0,0 +1,594 @@
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.cookbook import (
Category,
CookbookConfig,
CreateRecipeResponse,
DeleteRecipeResponse,
ImportRecipeResponse,
Keyword,
ListCategoriesResponse,
ListKeywordsResponse,
ListRecipesResponse,
Recipe,
RecipeStub,
ReindexResponse,
SearchRecipesResponse,
UpdateRecipeResponse,
Version,
)
logger = logging.getLogger(__name__)
def configure_cookbook_tools(mcp: FastMCP):
@mcp.resource("cookbook://version")
async def cookbook_get_version():
"""Get the Cookbook app and API version"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
version_data = await client.cookbook.get_version()
return Version(**version_data)
@mcp.resource("cookbook://config")
async def cookbook_get_config():
"""Get the Cookbook app configuration"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
config_data = await client.cookbook.get_config()
return CookbookConfig(**config_data)
@mcp.resource("nc://Cookbook/{recipe_id}")
async def nc_cookbook_get_recipe_resource(recipe_id: int):
"""Get a recipe by ID using resource URI"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
try:
recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
This extracts recipe data from websites that use schema.org Recipe markup.
Many popular recipe sites support this standard."""
client = get_client(ctx)
try:
recipe_data = await client.cookbook.import_recipe(url)
recipe = Recipe(**recipe_data)
return ImportRecipeResponse(
recipe=recipe,
recipe_id=recipe.id or "unknown",
)
except RequestError as e:
# RequestError can have empty str() - get details from exception attributes
error_detail = (
str(e)
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
)
raise McpError(
ErrorData(
code=-1,
message=f"Network error importing recipe from {url}: {error_detail}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 400:
raise McpError(
ErrorData(
code=-1,
message=f"Invalid URL or missing 'url' field: {url}",
)
)
elif e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message="A recipe with this name already exists. Import aborted.",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to import recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to import recipe from {url}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.list_recipes()
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list recipes: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
client = get_client(ctx)
try:
recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_cookbook_create_recipe(
name: str,
description: str | None = None,
ingredients: list[str] | None = None,
instructions: list[str] | None = None,
url: str | None = None,
prep_time: str | None = None,
cook_time: str | None = None,
total_time: str | None = None,
recipe_yield: int | None = None,
category: str | None = None,
keywords: str | None = None,
ctx: Context = None,
) -> CreateRecipeResponse:
"""Create a new recipe.
Required: name
Optional: All other recipe fields following schema.org/Recipe format.
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
client = get_client(ctx)
recipe_data = {"name": name}
if description:
recipe_data["description"] = description
if ingredients:
recipe_data["recipeIngredient"] = ingredients
if instructions:
recipe_data["recipeInstructions"] = instructions
if url:
recipe_data["url"] = url
if prep_time:
recipe_data["prepTime"] = prep_time
if cook_time:
recipe_data["cookTime"] = cook_time
if total_time:
recipe_data["totalTime"] = total_time
if recipe_yield:
recipe_data["recipeYield"] = recipe_yield
if category:
recipe_data["recipeCategory"] = category
if keywords:
recipe_data["keywords"] = keywords
try:
recipe_id = await client.cookbook.create_recipe(recipe_data)
return CreateRecipeResponse(id=recipe_id)
except HTTPStatusError as e:
if e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message=f"A recipe with name '{name}' already exists",
)
)
elif e.response.status_code == 422:
raise McpError(
ErrorData(
code=-1,
message="Recipe name is required and cannot be empty",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to create recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to create recipe: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_update_recipe(
recipe_id: int,
name: str | None = None,
description: str | None = None,
ingredients: list[str] | None = None,
instructions: list[str] | None = None,
url: str | None = None,
prep_time: str | None = None,
cook_time: str | None = None,
total_time: str | None = None,
recipe_yield: int | None = None,
category: str | None = None,
keywords: str | None = None,
ctx: Context = None,
) -> UpdateRecipeResponse:
"""Update an existing recipe.
Provide only the fields you want to update. Unspecified fields remain unchanged."""
client = get_client(ctx)
# First get the current recipe
try:
current_recipe = await client.cookbook.get_recipe(recipe_id)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}",
)
)
# Update only specified fields
recipe_data = current_recipe.copy()
if name is not None:
recipe_data["name"] = name
if description is not None:
recipe_data["description"] = description
if ingredients is not None:
recipe_data["recipeIngredient"] = ingredients
if instructions is not None:
recipe_data["recipeInstructions"] = instructions
if url is not None:
recipe_data["url"] = url
if prep_time is not None:
recipe_data["prepTime"] = prep_time
if cook_time is not None:
recipe_data["cookTime"] = cook_time
if total_time is not None:
recipe_data["totalTime"] = total_time
if recipe_yield is not None:
recipe_data["recipeYield"] = recipe_yield
if category is not None:
recipe_data["recipeCategory"] = category
if keywords is not None:
recipe_data["keywords"] = keywords
try:
updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data)
return UpdateRecipeResponse(id=updated_id)
except HTTPStatusError as e:
if e.response.status_code == 422:
raise McpError(
ErrorData(
code=-1,
message="Recipe name is required and cannot be empty",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to update recipe {recipe_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
) -> DeleteRecipeResponse:
"""Delete a recipe permanently"""
logger.info("Deleting recipe %s", recipe_id)
client = get_client(ctx)
try:
message = await client.cookbook.delete_recipe(recipe_id)
return DeleteRecipeResponse(
status_code=200,
message=message,
deleted_id=recipe_id,
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to delete recipe {recipe_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_search_recipes(
query: str, ctx: Context
) -> SearchRecipesResponse:
"""Search for recipes by keywords, tags, and categories"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.search_recipes(query)
recipes = [RecipeStub(**r) for r in recipes_data]
return SearchRecipesResponse(
recipes=recipes, query=query, total_found=len(recipes)
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to search recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message="Search failed: server error",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Search failed: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
Note: A category name of '*' indicates recipes with no category."""
client = get_client(ctx)
try:
categories_data = await client.cookbook.list_categories()
categories = [Category(**c) for c in categories_data]
return ListCategoriesResponse(categories=categories)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list categories",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list categories: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
) -> ListRecipesResponse:
"""Get all recipes in a specific category.
Use '_' as the category name to get recipes with no category."""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.get_recipes_in_category(category)
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to access recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message=f"Could not find category '{category}'",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get recipes in category: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
client = get_client(ctx)
try:
keywords_data = await client.cookbook.list_keywords()
keywords = [Keyword(**k) for k in keywords_data]
return ListKeywordsResponse(keywords=keywords)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list keywords",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list keywords: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
) -> ListRecipesResponse:
"""Get all recipes that have specific keywords/tags"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to access recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message="Failed to get recipes with keywords: server error",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get recipes with keywords: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_set_config(
folder: str | None = None,
update_interval: int | None = None,
print_image: bool | None = None,
ctx: Context = None,
) -> ReindexResponse:
"""Set Cookbook app configuration.
Args:
folder: Recipe folder path in user's files
update_interval: Automatic rescan interval in minutes
print_image: Whether to print images with recipes"""
client = get_client(ctx)
config_data = {}
if folder is not None:
config_data["folder"] = folder
if update_interval is not None:
config_data["update_interval"] = update_interval
if print_image is not None:
config_data["print_image"] = print_image
try:
result = await client.cookbook.set_config(config_data)
return ReindexResponse(status_code=200, message=str(result))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to set configuration",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to set configuration: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
This rebuilds the search index and should be used after manual file changes."""
client = get_client(ctx)
try:
message = await client.cookbook.reindex()
return ReindexResponse(status_code=200, message=message)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to reindex",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to reindex: server error ({e.response.status_code})",
)
)
+584
View File
@@ -0,0 +1,584 @@
import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.deck import (
CardOperationResponse,
CreateBoardResponse,
CreateCardResponse,
CreateLabelResponse,
CreateStackResponse,
DeckBoard,
DeckCard,
DeckLabel,
DeckStack,
LabelOperationResponse,
StackOperationResponse,
)
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()
await ctx.warning("This message is deprecated, use the deck_get_board instead")
client = get_client(ctx)
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_board tool instead"
)
client = get_client(ctx)
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_stacks tool instead"
)
client = get_client(ctx)
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_stack tool instead"
)
client = get_client(ctx)
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_cards tool instead"
)
client = get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return [card.model_dump() for card in stack.cards]
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_card tool instead"
)
client = get_client(ctx)
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_labels tool instead"
)
client = get_client(ctx)
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()
await ctx.warning(
"This resource is deprecated, use the deck_get_label tool instead"
)
client = get_client(ctx)
label = await client.deck.get_label(board_id, label_id)
return label.model_dump()
# Read Tools (converted from resources)
@mcp.tool()
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards"""
client = get_client(ctx)
boards = await client.deck.get_boards()
return boards
@mcp.tool()
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
client = get_client(ctx)
board = await client.deck.get_board(board_id)
return board
@mcp.tool()
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board"""
client = get_client(ctx)
stacks = await client.deck.get_stacks(board_id)
return stacks
@mcp.tool()
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack"""
client = get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
return stack
@mcp.tool()
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
"""Get all cards in a Nextcloud Deck stack"""
client = get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return stack.cards
return []
@mcp.tool()
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard:
"""Get details of a specific Nextcloud Deck card"""
client = get_client(ctx)
card = await client.deck.get_card(board_id, stack_id, card_id)
return card
@mcp.tool()
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board"""
client = get_client(ctx)
board = await client.deck.get_board(board_id)
return board.labels
@mcp.tool()
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label"""
client = get_client(ctx)
label = await client.deck.get_label(board_id, label_id)
return label
# Create/Update/Delete 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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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,
)
+396
View File
@@ -0,0 +1,396 @@
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.notes import (
AppendContentResponse,
CreateNoteResponse,
DeleteNoteResponse,
Note,
NoteSearchResult,
NotesSettings,
SearchNotesResponse,
UpdateNoteResponse,
)
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 = get_client(ctx)
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_resource(note_id: int, attachment_filename: str):
"""Get a specific attachment from a note"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
# Assuming a method get_note_attachment exists in the client
# This method should return the raw content and determine the mime type
content, mime_type = await client.webdav.get_note_attachment(
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_resource(note_id: int):
"""Get user note using note id"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error retrieving note {note_id}: {str(e)}",
)
)
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 = get_client(ctx)
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 RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
)
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_get_note tool.
If the note has been modified by someone else since you retrieved it,
the update will fail with a 412 error."""
logger.info("Updating note %s", note_id)
client = get_client(ctx)
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 RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
)
)
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 = get_client(ctx)
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 RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error appending to note {note_id}: {str(e)}",
)
)
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 = get_client(ctx)
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 RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
)
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_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID"""
client = get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to note {note_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve note {note_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
"""Get a specific attachment from a note"""
client = get_client(ctx)
try:
content, mime_type = await client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename
)
return {
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
"mimeType": mime_type,
"data": content,
}
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(
code=-1,
message=f"Attachment {attachment_filename} not found for note {note_id}",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied to attachment {attachment_filename} for note {note_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve attachment: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
logger.info("Deleting note %s", note_id)
client = get_client(ctx)
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 RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
)
)
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})",
)
)
+134
View File
@@ -0,0 +1,134 @@
"""MCP tools for Nextcloud file/folder sharing operations."""
import json
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.context import get_client
def configure_sharing_tools(mcp: FastMCP):
"""Configure sharing-related MCP tools.
Args:
mcp: FastMCP server instance
"""
@mcp.tool()
async def nc_share_create(
path: str,
share_with: str,
ctx: Context,
share_type: int = 0,
permissions: int = 1,
) -> str:
"""Create a share for a file or folder in Nextcloud.
Share a file or folder with another user or group. The authenticated user
must own the file/folder being shared.
Args:
path: Path to file/folder to share (relative to your files, e.g., "/document.txt")
share_with: Username (for user share) or group name (for group share)
share_type: Share type - 0 for user (default), 1 for group, 3 for public link
permissions: Share permissions (default: 1 for read-only):
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
JSON string with share information including share ID
"""
client = get_client(ctx)
share_data = await client.sharing.create_share(
path=path,
share_with=share_with,
share_type=share_type,
permissions=permissions,
)
return json.dumps(share_data, indent=2)
@mcp.tool()
async def nc_share_delete(share_id: int, ctx: Context) -> str:
"""Delete a share by its ID.
Remove a share that you created. You must be the owner of the share.
Args:
share_id: The ID of the share to delete
Returns:
JSON string confirming deletion
"""
client = get_client(ctx)
await client.sharing.delete_share(share_id)
return json.dumps(
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
)
@mcp.tool()
async def nc_share_get(share_id: int, ctx: Context) -> str:
"""Get information about a specific share.
Retrieve details about a share by its ID. You must have access to the share
(either as owner or recipient).
Args:
share_id: The ID of the share
Returns:
JSON string with share information
"""
client = get_client(ctx)
share_data = await client.sharing.get_share(share_id)
return json.dumps(share_data, indent=2)
@mcp.tool()
async def nc_share_list(
ctx: Context, path: str | None = None, shared_with_me: bool = False
) -> str:
"""List shares created by you or shared with you.
Args:
path: Optional path to filter shares for a specific file/folder
shared_with_me: If True, list shares that others shared with you.
If False (default), list shares you created.
Returns:
JSON string with list of shares
"""
client = get_client(ctx)
shares = await client.sharing.list_shares(
path=path, shared_with_me=shared_with_me
)
return json.dumps(shares, indent=2)
@mcp.tool()
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
Modify the permissions for a share you created. You must be the owner.
Args:
share_id: The ID of the share to update
permissions: New permissions value:
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
JSON string with updated share information
"""
client = get_client(ctx)
share_data = await client.sharing.update_share(
share_id=share_id, permissions=permissions
)
return json.dumps(share_data, indent=2)
+57
View File
@@ -0,0 +1,57 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.context import get_client
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 = get_client(ctx)
return await client.tables.list_tables()
@mcp.tool()
async def nc_tables_get_schema(table_id: int, ctx: Context):
"""Get the schema/structure of a specific table including columns and views"""
client = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
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 = get_client(ctx)
return await client.tables.update_row(row_id, data)
@mcp.tool()
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
client = get_client(ctx)
return await client.tables.delete_row(row_id)
+343
View File
@@ -0,0 +1,343 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
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
"""
client = get_client(ctx)
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
"""
client = get_client(ctx)
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
"""
client = get_client(ctx)
# 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)
"""
client = get_client(ctx)
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)
"""
client = get_client(ctx)
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)
"""
client = get_client(ctx)
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)
"""
client = get_client(ctx)
return await client.webdav.copy_resource(
source_path, destination_path, overwrite
)
@mcp.tool()
async def nc_webdav_search_files(
ctx: Context,
scope: str = "",
name_pattern: str | None = None,
mime_type: str | None = None,
only_favorites: bool = False,
limit: int | None = None,
) -> SearchFilesResponse:
"""Search for files in NextCloud using WebDAV SEARCH.
This is a high-level search tool that supports common search patterns.
For more complex queries, use the specific search tools.
Args:
scope: Directory path to search in (empty string for user root)
name_pattern: File name pattern (supports % wildcard, e.g., "%.txt" for all text files)
mime_type: MIME type to filter by (supports % wildcard, e.g., "image/%" for all images)
only_favorites: If True, only return favorited files
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
# Build where conditions based on filters
conditions = []
if name_pattern:
conditions.append(
f"""
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>{name_pattern}</d:literal>
</d:like>
"""
)
if mime_type:
conditions.append(
f"""
<d:like>
<d:prop>
<d:getcontenttype/>
</d:prop>
<d:literal>{mime_type}</d:literal>
</d:like>
"""
)
if only_favorites:
conditions.append(
"""
<d:eq>
<d:prop>
<oc:favorite/>
</d:prop>
<d:literal>1</d:literal>
</d:eq>
"""
)
# Combine conditions with AND if multiple
if len(conditions) > 1:
where_conditions = f"""
<d:and>
{"".join(conditions)}
</d:and>
"""
elif len(conditions) == 1:
where_conditions = conditions[0]
else:
where_conditions = None
# Include extended properties
properties = [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
"fileid",
"favorite",
]
results = await client.webdav.search_files(
scope=scope,
where_conditions=where_conditions,
properties=properties,
limit=limit,
)
# Convert to FileInfo models
file_infos = [FileInfo(**result) for result in results]
# Build filters applied dict
filters = {}
if name_pattern:
filters["name_pattern"] = name_pattern
if mime_type:
filters["mime_type"] = mime_type
if only_favorites:
filters["only_favorites"] = True
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied=filters if filters else None,
)
@mcp.tool()
async def nc_webdav_find_by_name(
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
"""Find files by name pattern in NextCloud.
Args:
pattern: Name pattern to search for (supports % wildcard)
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
results = await client.webdav.find_by_name(
pattern=pattern, scope=scope, limit=limit
)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied={"name_pattern": pattern},
)
@mcp.tool()
async def nc_webdav_find_by_type(
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
"""Find files by MIME type in NextCloud.
Args:
mime_type: MIME type to search for (supports % wildcard)
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
results = await client.webdav.find_by_type(
mime_type=mime_type, scope=scope, limit=limit
)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied={"mime_type": mime_type},
)
@mcp.tool()
async def nc_webdav_list_favorites(
ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
"""List all favorite files in NextCloud.
Args:
scope: Directory path to search in (empty string for all favorites)
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of favorite files
"""
client = get_client(ctx)
results = await client.webdav.list_favorites(scope=scope, limit=limit)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied={"only_favorites": True},
)
+66 -13
View File
@@ -1,28 +1,59 @@
[project]
name = "nextcloud-mcp-server"
version = "0.2.3"
description = ""
version = "0.17.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
{name = "Chris Coutinho", email = "chris@coutinho.io"}
]
readme = "README.md"
license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.9,<1.10)",
"mcp[cli] (>=1.18,<1.19)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=11.2.1,<12.0.0)"
"pillow (>=12.0.0,<12.1.0)",
"icalendar (>=6.0.0,<7.0.0)",
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Communications",
"Topic :: Internet :: WWW/HTTP",
]
[project.scripts]
nc-mcp-server = "nextcloud_mcp_server.server:run"
[project.urls]
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
[tool.pytest.ini_options]
anyio_mode = "auto"
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
log_cli = 1
log_cli_level = "WARN"
log_level = "WARN"
log_cli_level = "ERROR"
log_level = "ERROR"
markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
]
testpaths = [
"tests",
]
# Timeout settings to prevent tests from hanging indefinitely
timeout = 180 # 3 minutes default timeout per test (includes fixture setup)
timeout_func_only = false # Timeout includes fixture setup/teardown
[tool.commitizen]
name = "cz_conventional_commits"
@@ -32,15 +63,37 @@ version_provider = "uv"
update_changelog_on_bump = true
major_version_zero = true
[tool.ruff.lint]
extend-select = ["I"]
[tool.uv.sources]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
requires = ["uv_build>=0.9.4,<0.10.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "nextcloud_mcp_server"
module-root = ""
[dependency-groups]
dev = [
"black>=25.1.0",
"commitizen>=4.8.2",
"ipython>=9.2.0",
"playwright>=1.49.1",
"pytest>=8.3.5",
"pytest-cov>=6.1.1",
"pytest-playwright-asyncio>=0.7.1",
"pytest-timeout>=2.3.1",
"ruff>=0.11.13",
]
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
+2 -1
View File
@@ -1,7 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices"
"config:best-practices",
"mergeConfidence:all-badges"
],
"dependencyDashboard": true
}
+11
View File
@@ -0,0 +1,11 @@
"""Shared fixtures for calendar integration tests.
Note: The temporary_calendar fixture is defined in tests/conftest.py and uses
a shared session-scoped calendar to avoid Nextcloud rate limiting issues.
This conftest.py exists for any calendar-specific fixtures that might be needed
in the future.
"""
import logging
logger = logging.getLogger(__name__)
@@ -0,0 +1,398 @@
"""Integration tests for Calendar CalDAV operations.
Note: These tests use the shared temporary_calendar fixture from conftest.py
which reuses a session-scoped calendar to avoid Nextcloud rate limiting issues.
Each test cleans up its own events/todos but shares the same calendar.
"""
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
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
"""Create a temporary event for testing and clean up afterward.
Uses the shared temporary_calendar fixture from conftest.py which reuses
a session-scoped calendar to avoid Nextcloud rate limiting.
"""
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()}"
# caldav library raises generic Exception for missing events, not HTTPStatusError
with pytest.raises(Exception, match="not found"):
await nc_client.calendar.get_event(calendar_name, fake_uid)
logger.info(f"Correctly raised exception 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}"
# caldav library returns empty list for non-existent calendars, doesn't raise
# Testing that it doesn't crash and returns empty results
events = await nc_client.calendar.get_calendar_events(fake_calendar)
assert isinstance(events, list)
# Empty list is expected for non-existent calendar
assert len(events) == 0
logger.info("Error handling tests completed successfully")
@@ -0,0 +1,427 @@
"""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 uuid
from datetime import datetime, timedelta
import pytest
logger = logging.getLogger(__name__)
@pytest.mark.integration
async def test_calendar_event_custom_fields_preservation(nc_client):
"""Test that custom iCal fields are preserved during round-trip update 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:
# Get the calendar object from the caldav library
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
# Now manually inject custom iCal properties into the raw data
# 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"""
# Update the event's raw data and save
event.data = custom_ical
await event.save()
logger.info(f"Injected custom iCal properties into event {event_uid}")
# Reload the event to confirm custom fields are present
await event.load()
raw_ical_before = event.data
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")
# Reload the event to see if custom fields survived
await event.load()
raw_ical_after = event.data
logger.info("Raw iCal after update:")
logger.info(raw_ical_after)
# THIS IS THE CRITICAL TEST - custom fields should be preserved
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 during update")
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):
"""Test that extended iCal properties are preserved during round-trip update operations."""
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:
# Get the calendar object and event
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
# 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"""
# Update the event's raw data and save
event.data = extended_ical
await event.save()
# Reload to verify extended properties are present
await event.load()
original_ical = event.data
# 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)
# Reload the event to check what survived the round-trip
await event.load()
updated_ical = event.data
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}")
if lost:
logger.error(f"Properties that were LOST: {lost}")
# Assert that all extended properties were preserved
assert len(lost) == 0, (
f"Round-trip update lost {len(lost)} extended properties: {lost}"
)
logger.info("✓ All extended properties preserved during update")
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,498 @@
"""Integration tests for Calendar VTODO (task) 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
async def temporary_todo(nc_client: NextcloudClient, temporary_calendar: str):
"""Create a temporary todo for testing and clean up afterward."""
todo_uid = None
calendar_name = temporary_calendar
# Create a test todo
tomorrow = datetime.now() + timedelta(days=1)
todo_data = {
"summary": f"Test Task {uuid.uuid4().hex[:8]}",
"description": "Test todo created by integration tests",
"status": "NEEDS-ACTION",
"priority": 5,
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
"categories": "testing",
}
try:
logger.info(f"Creating temporary todo in calendar: {calendar_name}")
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result.get("uid")
if not todo_uid:
pytest.fail("Failed to create temporary todo")
logger.info(f"Created temporary todo with UID: {todo_uid}")
yield {"uid": todo_uid, "calendar_name": calendar_name, "data": todo_data}
finally:
# Cleanup
if todo_uid:
try:
logger.info(f"Cleaning up temporary todo: {todo_uid}")
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
logger.info(f"Successfully deleted temporary todo: {todo_uid}")
except HTTPStatusError as e:
if e.response.status_code != 404:
logger.error(f"Error deleting temporary todo {todo_uid}: {e}")
except Exception as e:
logger.error(
f"Unexpected error deleting temporary todo {todo_uid}: {e}"
)
# ============= Basic CRUD Tests =============
async def test_create_and_delete_todo(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating and deleting a basic todo."""
calendar_name = temporary_calendar
# Create todo
tomorrow = datetime.now() + timedelta(days=1)
todo_data = {
"summary": "Integration Test Task",
"description": "Test task for integration testing",
"status": "NEEDS-ACTION",
"priority": 3,
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
"categories": "testing,integration",
}
try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
assert "uid" in result
assert result["status_code"] in [200, 201, 204]
todo_uid = result["uid"]
logger.info(f"Created todo with UID: {todo_uid}")
# Verify todo was created by listing todos
todos = await nc_client.calendar.list_todos(calendar_name)
todo_uids = [todo.get("uid") for todo in todos]
assert todo_uid in todo_uids
# Find our todo in the list
our_todo = next((t for t in todos if t.get("uid") == todo_uid), None)
assert our_todo is not None
assert our_todo["summary"] == "Integration Test Task"
assert our_todo["status"] == "NEEDS-ACTION"
assert our_todo["priority"] == 3
# Delete todo
delete_result = await nc_client.calendar.delete_todo(calendar_name, todo_uid)
assert delete_result["status_code"] in [200, 204, 404]
logger.info(f"Successfully deleted todo: {todo_uid}")
except Exception as e:
logger.error(f"Test failed: {e}")
raise
async def test_list_todos(nc_client: NextcloudClient, temporary_calendar: str):
"""Test listing todos in a calendar."""
calendar_name = temporary_calendar
# Create multiple todos
todo_uids = []
for i in range(3):
todo_data = {
"summary": f"Test Task {i + 1}",
"description": f"Task number {i + 1}",
"status": "NEEDS-ACTION",
"priority": i + 1,
}
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uids.append(result["uid"])
try:
# List todos
todos = await nc_client.calendar.list_todos(calendar_name)
assert isinstance(todos, list)
assert len(todos) >= 3 # At least our 3 todos
# Check structure
for todo in todos:
assert "uid" in todo
assert "summary" in todo
assert "status" in todo
assert "priority" in todo
# Verify our todos are in the list
listed_uids = [todo["uid"] for todo in todos]
for uid in todo_uids:
assert uid in listed_uids
logger.info(f"Found {len(todos)} todos in calendar")
finally:
# Cleanup
for uid in todo_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
async def test_update_todo(nc_client: NextcloudClient, temporary_todo: dict):
"""Test updating an existing todo."""
calendar_name = temporary_todo["calendar_name"]
todo_uid = temporary_todo["uid"]
# Update todo data
updated_data = {
"summary": "Updated Test Task Title",
"description": "Updated description for test task",
"status": "IN-PROCESS",
"priority": 1, # High priority
"percent_complete": 50,
}
try:
result = await nc_client.calendar.update_todo(
calendar_name, todo_uid, updated_data
)
assert result["uid"] == todo_uid
# Verify updates by listing todos
todos = await nc_client.calendar.list_todos(calendar_name)
updated_todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert updated_todo is not None
assert updated_todo["summary"] == "Updated Test Task Title"
assert updated_todo["description"] == "Updated description for test task"
assert updated_todo["status"] == "IN-PROCESS"
assert updated_todo["priority"] == 1
assert updated_todo["percent_complete"] == 50
logger.info(f"Successfully updated todo: {todo_uid}")
except Exception as e:
logger.error(f"Todo update test failed: {e}")
raise
async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: str):
"""Test creating a todo with start, due, and completed dates."""
calendar_name = temporary_calendar
now = datetime.now()
start_date = now + timedelta(days=1)
due_date = now + timedelta(days=7)
todo_data = {
"summary": "Task with Dates",
"description": "Test task with various date fields",
"status": "NEEDS-ACTION",
"dtstart": start_date.strftime("%Y-%m-%dT09:00:00"),
"due": due_date.strftime("%Y-%m-%dT17:00:00"),
}
try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"]
logger.info(f"Created todo with dates, UID: {todo_uid}")
# Verify dates
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert created_todo is not None
assert created_todo["summary"] == "Task with Dates"
assert "dtstart" in created_todo
assert "due" in created_todo
# Cleanup
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception as e:
logger.error(f"Date handling test failed: {e}")
raise
# ============= Advanced Feature Tests =============
async def test_todo_status_transitions(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test transitioning through different todo statuses."""
calendar_name = temporary_calendar
todo_data = {
"summary": "Status Transition Test",
"description": "Testing status changes",
"status": "NEEDS-ACTION",
}
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"]
try:
# Transition: NEEDS-ACTION → IN-PROCESS
await nc_client.calendar.update_todo(
calendar_name,
todo_uid,
{"status": "IN-PROCESS", "percent_complete": 25},
)
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert todo["status"] == "IN-PROCESS"
assert todo["percent_complete"] == 25
# Transition: IN-PROCESS → COMPLETED
completed_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
await nc_client.calendar.update_todo(
calendar_name,
todo_uid,
{
"status": "COMPLETED",
"percent_complete": 100,
"completed": completed_time,
},
)
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert todo["status"] == "COMPLETED"
assert todo["percent_complete"] == 100
assert "completed" in todo
logger.info(f"Successfully transitioned todo through statuses: {todo_uid}")
finally:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
async def test_todo_priority_levels(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test different priority levels (0=undefined, 1=highest, 9=lowest)."""
calendar_name = temporary_calendar
priorities = [0, 1, 5, 9]
priority_labels = {0: "Undefined", 1: "Highest", 5: "Medium", 9: "Lowest"}
todo_uids = []
try:
# Create todos with different priorities
for priority in priorities:
todo_data = {
"summary": f"Priority {priority} Task ({priority_labels[priority]})",
"status": "NEEDS-ACTION",
"priority": priority,
}
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uids.append((result["uid"], priority))
# Verify all priorities
todos = await nc_client.calendar.list_todos(calendar_name)
for uid, expected_priority in todo_uids:
todo = next((t for t in todos if t["uid"] == uid), None)
assert todo is not None
assert todo["priority"] == expected_priority
logger.info(f"Successfully tested priority levels: {priorities}")
finally:
# Cleanup
for uid, _ in todo_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
async def test_todo_with_categories(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating a todo with multiple categories."""
calendar_name = temporary_calendar
todo_data = {
"summary": "Task with Categories",
"description": "Testing category support",
"status": "NEEDS-ACTION",
"categories": "work,meeting,important,quarterly",
}
try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"]
logger.info(f"Created todo with categories, UID: {todo_uid}")
# Verify categories
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert created_todo is not None
assert "categories" in created_todo
categories_str = created_todo["categories"]
assert "work" in categories_str
assert "meeting" in categories_str
assert "important" in categories_str
assert "quarterly" in categories_str
# Cleanup
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception as e:
logger.error(f"Categories test failed: {e}")
raise
async def test_search_todos_across_calendars(
nc_client: NextcloudClient, temporary_calendar: str, shared_calendar_2: str
):
"""Test searching for todos across multiple calendars.
Uses two shared test calendars to avoid rate limiting.
"""
# Use existing shared calendars to avoid rate limits
cal1_name = temporary_calendar # First shared test calendar
cal2_name = shared_calendar_2 # Second shared test calendar
try:
# Create todos in both calendars
todo1_data = {"summary": "Task in Calendar 1", "status": "NEEDS-ACTION"}
todo2_data = {"summary": "Task in Calendar 2", "status": "IN-PROCESS"}
result1 = await nc_client.calendar.create_todo(cal1_name, todo1_data)
result2 = await nc_client.calendar.create_todo(cal2_name, todo2_data)
# Search across all calendars
all_todos = await nc_client.calendar.search_todos_across_calendars()
assert isinstance(all_todos, list)
# Find our todos
todo1 = next((t for t in all_todos if t["uid"] == result1["uid"]), None)
todo2 = next((t for t in all_todos if t["uid"] == result2["uid"]), None)
assert todo1 is not None
assert todo2 is not None
assert "calendar_name" in todo1
assert "calendar_name" in todo2
assert todo1["calendar_name"] == cal1_name
assert todo2["calendar_name"] == cal2_name
logger.info(f"Found {len(all_todos)} todos across all calendars")
finally:
# Cleanup: Delete only the todos we created (calendars are reused/built-in)
try:
await nc_client.calendar.delete_todo(cal1_name, result1["uid"])
except Exception:
pass
try:
await nc_client.calendar.delete_todo(cal2_name, result2["uid"])
except Exception:
pass
# ============= Edge Case Tests =============
async def test_get_nonexistent_todo(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test attempting to retrieve a non-existent todo."""
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
# List todos to ensure it doesn't exist
todos = await nc_client.calendar.list_todos(calendar_name)
matching_todos = [t for t in todos if t.get("uid") == fake_uid]
assert len(matching_todos) == 0
logger.info(f"Verified nonexistent todo UID: {fake_uid}")
async def test_delete_nonexistent_todo(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test deleting a non-existent todo."""
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
result = await nc_client.calendar.delete_todo(calendar_name, fake_uid)
assert result["status_code"] == 404
logger.info(f"Correctly got 404 for deleting nonexistent todo: {fake_uid}")
async def test_list_todos_with_filters(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test listing todos with various filters."""
calendar_name = temporary_calendar
# Create todos with different statuses and priorities
test_todos = [
{
"summary": "High Priority Task",
"status": "NEEDS-ACTION",
"priority": 1,
"categories": "urgent",
},
{
"summary": "In Progress Task",
"status": "IN-PROCESS",
"priority": 5,
"categories": "work",
},
{
"summary": "Low Priority Task",
"status": "NEEDS-ACTION",
"priority": 9,
"categories": "someday",
},
]
created_uids = []
try:
# Create test todos
for todo_data in test_todos:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
created_uids.append(result["uid"])
# Test basic list without filters
all_todos = await nc_client.calendar.list_todos(calendar_name)
assert len(all_todos) >= 3
# Verify all our todos are in the list
our_todo_uids = [t["uid"] for t in all_todos if t["uid"] in created_uids]
assert len(our_todo_uids) == 3
logger.info(f"Successfully created and listed {len(created_uids)} test todos")
finally:
# Cleanup
for uid in created_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
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
+386
View File
@@ -0,0 +1,386 @@
import asyncio
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
async def test_cookbook_version(nc_client: NextcloudClient):
"""Test getting Cookbook app version."""
logger.info("Getting Cookbook app version")
version_data = await nc_client.cookbook.get_version()
assert "cookbook_version" in version_data
assert "api_version" in version_data
logger.info(f"Cookbook version: {version_data}")
async def test_cookbook_config(nc_client: NextcloudClient):
"""Test getting Cookbook app configuration."""
logger.info("Getting Cookbook app configuration")
config_data = await nc_client.cookbook.get_config()
# Config may be empty initially, just verify we can get it
assert isinstance(config_data, dict)
logger.info(f"Cookbook config: {config_data}")
async def test_cookbook_list_recipes(nc_client: NextcloudClient):
"""Test listing all recipes."""
logger.info("Listing all recipes")
recipes = await nc_client.cookbook.list_recipes()
assert isinstance(recipes, list)
logger.info(f"Found {len(recipes)} recipes")
async def test_cookbook_create_and_read_recipe(nc_client: NextcloudClient):
"""Test creating a recipe and reading it back."""
# Create a test recipe
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"description": "A test recipe for integration testing",
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
"recipeInstructions": [
"Mix ingredients",
"Cook for 20 minutes",
"Serve hot",
],
"recipeCategory": "Test",
"keywords": "test,integration",
"recipeYield": 4,
"prepTime": "PT15M",
"cookTime": "PT20M",
"totalTime": "PT35M",
}
logger.info(f"Creating recipe: {recipe_name}")
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
logger.info(f"Created recipe with ID: {recipe_id}")
try:
# Read the recipe back
logger.info(f"Reading recipe ID: {recipe_id}")
retrieved_recipe = await nc_client.cookbook.get_recipe(recipe_id)
assert retrieved_recipe["name"] == recipe_name
assert (
retrieved_recipe["description"] == "A test recipe for integration testing"
)
assert len(retrieved_recipe["recipeIngredient"]) == 3
assert len(retrieved_recipe["recipeInstructions"]) == 3
assert retrieved_recipe["recipeCategory"] == "Test"
assert retrieved_recipe["recipeYield"] == 4
logger.info(f"Successfully verified recipe: {recipe_name}")
finally:
# Clean up
logger.info(f"Deleting recipe ID: {recipe_id}")
await nc_client.cookbook.delete_recipe(recipe_id)
logger.info(f"Successfully deleted recipe ID: {recipe_id}")
async def test_cookbook_update_recipe(nc_client: NextcloudClient):
"""Test updating a recipe."""
# Create a test recipe
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"description": "Original description",
"recipeIngredient": ["100g flour"],
"recipeInstructions": ["Mix ingredients"],
"recipeCategory": "Original",
}
logger.info(f"Creating recipe for update test: {recipe_name}")
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
try:
# Get the current recipe first
current_recipe = await nc_client.cookbook.get_recipe(recipe_id)
# Update the recipe with all required fields
updated_data = current_recipe.copy()
updated_data["description"] = "Updated description"
updated_data["recipeIngredient"] = ["100g flour", "2 eggs"]
updated_data["recipeInstructions"] = ["Mix ingredients", "Cook"]
updated_data["recipeCategory"] = "Updated"
logger.info(f"Updating recipe ID: {recipe_id}")
updated_id = await nc_client.cookbook.update_recipe(recipe_id, updated_data)
assert updated_id == recipe_id
# Verify the update
await asyncio.sleep(1) # Allow propagation
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
assert updated_recipe["description"] == "Updated description"
assert len(updated_recipe["recipeIngredient"]) == 2
assert len(updated_recipe["recipeInstructions"]) == 2
assert updated_recipe["recipeCategory"] == "Updated"
logger.info(f"Successfully updated recipe ID: {recipe_id}")
finally:
# Clean up
logger.info(f"Deleting recipe ID: {recipe_id}")
await nc_client.cookbook.delete_recipe(recipe_id)
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
"""Test deleting a non-existent recipe.
Note: The Cookbook API may return 502 or succeed silently for non-existent IDs
rather than 404. This test verifies the behavior."""
non_existent_id = 999999999
logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}")
try:
result = await nc_client.cookbook.delete_recipe(non_existent_id)
logger.info(f"Delete returned: {result}")
# API may succeed silently or return an error message
assert isinstance(result, str)
except HTTPStatusError as e:
# API may return 404 or 502 for non-existent recipes
assert e.response.status_code in [404, 502]
logger.info(f"Delete correctly failed with {e.response.status_code}")
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
"""Test importing a recipe from a URL.
This is the key feature test - importing recipes from URLs using schema.org metadata.
Uses an nginx container to serve reliable, controlled test data.
"""
# Use the nginx container hostname within the Docker network
test_url = "http://recipes/black-pepper-tofu"
logger.info(f"Importing recipe from nginx container: {test_url}")
try:
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
logger.info(f"Successfully imported recipe: {imported_recipe.get('name')}")
# Verify basic recipe structure
assert "name" in imported_recipe
assert imported_recipe["name"] == "Black Pepper Tofu"
assert "id" in imported_recipe
# Verify schema.org fields were imported correctly
assert imported_recipe.get("description")
assert len(imported_recipe.get("recipeIngredient", [])) > 0
assert len(imported_recipe.get("recipeInstructions", [])) > 0
assert imported_recipe.get("recipeCategory") == "Main Course"
assert "tofu" in imported_recipe.get("keywords", "").lower()
recipe_id = int(imported_recipe["id"])
# Verify we can read it back
retrieved = await nc_client.cookbook.get_recipe(recipe_id)
assert retrieved["name"] == imported_recipe["name"]
logger.info(f"Verified imported recipe ID: {recipe_id}")
# Clean up
logger.info(f"Deleting imported recipe ID: {recipe_id}")
await nc_client.cookbook.delete_recipe(recipe_id)
logger.info("Successfully deleted imported recipe")
except HTTPStatusError as e:
if e.response.status_code == 409:
# Recipe already exists - this is acceptable in tests
logger.warning("Recipe already exists (409 conflict)")
pytest.skip("Recipe already exists in test environment")
elif e.response.status_code == 400:
# URL couldn't be imported
logger.error(
f"Failed to import recipe from nginx container: {test_url}. "
f"Status: {e.response.status_code}, Response: {e.response.text}"
)
raise
else:
raise
async def test_cookbook_search_recipes(nc_client: NextcloudClient):
"""Test searching for recipes."""
# Create a test recipe with unique keywords
unique_keyword = f"testkeyword{uuid.uuid4().hex[:8]}"
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"description": f"Recipe for testing search with {unique_keyword}",
"keywords": unique_keyword,
"recipeIngredient": ["test ingredient"],
"recipeInstructions": ["test instruction"],
}
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
try:
# Allow time for indexing
await asyncio.sleep(2)
# Search for the recipe
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
search_results = await nc_client.cookbook.search_recipes(unique_keyword)
assert isinstance(search_results, list)
# Should find at least our recipe
assert len(search_results) > 0
# Verify our recipe is in the results
found = any(str(r.get("id")) == str(recipe_id) for r in search_results)
assert found, f"Recipe {recipe_id} not found in search results"
logger.info(f"Successfully found recipe {recipe_id} in search results")
finally:
# Clean up
logger.info(f"Deleting recipe ID: {recipe_id}")
await nc_client.cookbook.delete_recipe(recipe_id)
async def test_cookbook_list_categories(nc_client: NextcloudClient):
"""Test listing recipe categories."""
logger.info("Listing recipe categories")
categories = await nc_client.cookbook.list_categories()
assert isinstance(categories, list)
logger.info(f"Found {len(categories)} categories")
# Each category should have name and recipe_count
if categories:
assert "name" in categories[0]
assert "recipe_count" in categories[0]
async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient):
"""Test getting recipes in a specific category."""
# Create a recipe in a test category
unique_category = f"TestCategory{uuid.uuid4().hex[:8]}"
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"recipeCategory": unique_category,
"recipeIngredient": ["test"],
"recipeInstructions": ["test"],
}
logger.info(f"Creating recipe in category: {unique_category}")
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
try:
# Allow time for indexing
await asyncio.sleep(2)
# Get recipes in this category
logger.info(f"Getting recipes in category: {unique_category}")
recipes_in_category = await nc_client.cookbook.get_recipes_in_category(
unique_category
)
assert isinstance(recipes_in_category, list)
assert len(recipes_in_category) > 0
# Verify our recipe is in the results
found = any(str(r.get("id")) == str(recipe_id) for r in recipes_in_category)
assert found, f"Recipe {recipe_id} not found in category {unique_category}"
logger.info(f"Successfully found recipe in category {unique_category}")
finally:
# Clean up
logger.info(f"Deleting recipe ID: {recipe_id}")
await nc_client.cookbook.delete_recipe(recipe_id)
async def test_cookbook_list_keywords(nc_client: NextcloudClient):
"""Test listing recipe keywords."""
logger.info("Listing recipe keywords")
keywords = await nc_client.cookbook.list_keywords()
assert isinstance(keywords, list)
logger.info(f"Found {len(keywords)} keywords")
# Each keyword should have name and recipe_count
if keywords:
assert "name" in keywords[0]
assert "recipe_count" in keywords[0]
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
"""Test getting recipes with specific keywords.
Note: The keywords filtering may require exact keyword matches and sufficient
indexing time. This test uses a longer wait time."""
# Create a recipe with unique keywords
unique_keyword = f"testtag{uuid.uuid4().hex[:8]}"
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"keywords": f"{unique_keyword},integration",
"recipeIngredient": ["test"],
"recipeInstructions": ["test"],
}
logger.info(f"Creating recipe with keyword: {unique_keyword}")
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
try:
# Allow extra time for indexing
await asyncio.sleep(3)
# Trigger a reindex to ensure the recipe is indexed
await nc_client.cookbook.reindex()
await asyncio.sleep(2)
# Get recipes with this keyword
logger.info(f"Getting recipes with keyword: {unique_keyword}")
recipes_with_keywords = await nc_client.cookbook.get_recipes_with_keywords(
[unique_keyword]
)
assert isinstance(recipes_with_keywords, list)
# Keyword filtering might not find recipes immediately due to indexing
# Log the results for debugging
logger.info(
f"Found {len(recipes_with_keywords)} recipes with keyword {unique_keyword}"
)
if len(recipes_with_keywords) > 0:
# Verify our recipe is in the results if any are found
found = any(
str(r.get("id")) == str(recipe_id) for r in recipes_with_keywords
)
if found:
logger.info(f"Successfully found recipe with keyword {unique_keyword}")
else:
logger.warning(
f"Recipe {recipe_id} not in keyword results, but other recipes found"
)
else:
logger.warning(
f"No recipes found with keyword {unique_keyword} - may be indexing delay"
)
finally:
# Clean up
logger.info(f"Deleting recipe ID: {recipe_id}")
await nc_client.cookbook.delete_recipe(recipe_id)
async def test_cookbook_reindex(nc_client: NextcloudClient):
"""Test triggering a reindex of recipes."""
logger.info("Triggering recipe reindex")
result = await nc_client.cookbook.reindex()
# Should return a success message
assert isinstance(result, str)
logger.info(f"Reindex result: {result}")
+327
View File
@@ -0,0 +1,327 @@
import logging
import uuid
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
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}")
+404
View File
@@ -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}")
+187
View File
@@ -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
![Test Image MD](.attachments.{note_id}/{attachment_filename})
### 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.
@@ -1,7 +1,8 @@
import pytest
import asyncio
import logging
import time
import uuid # Keep uuid if needed for generating unique data within tests
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
@@ -13,15 +14,18 @@ logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
def test_notes_api_create_and_read(nc_client: NextcloudClient, temporary_note: dict):
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
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 = nc_client.notes_get_note(note_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"]
@@ -29,7 +33,8 @@ def test_notes_api_create_and_read(nc_client: NextcloudClient, temporary_note: d
assert read_note["category"] == created_note_data["category"]
logger.info(f"Successfully read and verified note ID: {note_id}")
def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
"""
Tests updating a note created by the fixture.
"""
@@ -42,7 +47,7 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
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 = nc_client.notes_update_note(
updated_note = await nc_client.notes.update(
note_id=note_id,
etag=original_etag,
title=update_title,
@@ -54,18 +59,23 @@ def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
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 (
updated_note["category"] == original_category
) # Verify category didn't change
assert "etag" in updated_note
assert updated_note["etag"] != original_etag # Etag must change
assert updated_note["etag"] != original_etag # Etag must change
# Optional: Verify update by reading again
time.sleep(1) # Allow potential propagation delay
read_updated_note = nc_client.notes_get_note(note_id=note_id)
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}")
def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: dict):
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.
"""
@@ -76,7 +86,7 @@ def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: d
# 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 = nc_client.notes_update_note(
first_updated_note = await nc_client.notes.update(
note_id=note_id,
etag=original_etag,
title=first_update_title,
@@ -86,32 +96,40 @@ def test_notes_api_update_conflict(nc_client: NextcloudClient, temporary_note: d
new_etag = first_updated_note["etag"]
assert new_etag != original_etag
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
time.sleep(1)
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}")
logger.info(
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
)
with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_update_note(
await nc_client.notes.update(
note_id=note_id,
etag=original_etag, # Use the stale etag
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
assert excinfo.value.response.status_code == 412 # Precondition Failed
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
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
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:
nc_client.notes_delete_note(note_id=non_existent_id)
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.")
logger.info(
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
)
def test_notes_api_append_content_to_existing_note(nc_client: NextcloudClient, temporary_note: dict):
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.
"""
@@ -122,28 +140,28 @@ def test_notes_api_append_content_to_existing_note(nc_client: NextcloudClient, t
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
logger.info(f"Appending content to note ID: {note_id}")
updated_note = nc_client.notes_append_content(
note_id=note_id,
content=append_text
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
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
time.sleep(1) # Allow potential propagation delay
read_note = nc_client.notes_get_note(note_id=note_id)
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}")
def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
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).
"""
@@ -151,11 +169,11 @@ def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
test_category = "Test"
logger.info(f"Creating empty note for append test")
empty_note = nc_client.notes_create_note(
logger.info("Creating empty note for append test")
empty_note = await nc_client.notes.create_note(
title=test_title,
content="", # Empty content
category=test_category
content="",
category=test_category, # Empty content
)
note_id = empty_note["id"]
@@ -163,29 +181,31 @@ def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
append_text = f"First content {uuid.uuid4().hex[:8]}"
logger.info(f"Appending content to empty note ID: {note_id}")
updated_note = nc_client.notes_append_content(
note_id=note_id,
content=append_text
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
time.sleep(1)
read_note = nc_client.notes_get_note(note_id=note_id)
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:
nc_client.notes_delete_note(note_id=note_id)
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}")
def test_notes_api_append_content_multiple_times(nc_client: NextcloudClient, temporary_note: dict):
async def test_notes_api_append_content_multiple_times(
nc_client: NextcloudClient, temporary_note: dict
):
"""
Tests appending content multiple times to verify separator behavior.
"""
@@ -199,30 +219,31 @@ def test_notes_api_append_content_multiple_times(nc_client: NextcloudClient, tem
logger.info(f"Performing multiple appends to note ID: {note_id}")
# First append
updated_note = nc_client.notes_append_content(
note_id=note_id,
content=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 = nc_client.notes_append_content(
note_id=note_id,
content=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
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
time.sleep(1)
read_note = nc_client.notes_get_note(note_id=note_id)
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}")
def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
"""
Tests that appending to a non-existent note fails with 404.
"""
@@ -230,11 +251,10 @@ def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_append_content(
note_id=non_existent_id,
content="This should fail"
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.")
# --- Attachment tests moved to test_attachments.py ---
logger.info(
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
)
+535
View File
@@ -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."
)
+103
View File
@@ -0,0 +1,103 @@
"""Integration tests for OAuth authentication."""
import logging
import os
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.auth import BearerAuth
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
# OAuth Client Tests
async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can fetch capabilities."""
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
assert "ocs" in capabilities
logger.info(
f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}"
)
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can list notes."""
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
assert isinstance(notes, list)
logger.info(f"OAuth client successfully listed {len(notes)} notes")
async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can create and delete a note."""
# Create note
note_title = "OAuth Test Note"
note_content = "This note was created with OAuth authentication"
created_note = await nc_oauth_client.notes.create_note(
title=note_title, content=note_content
)
assert created_note is not None
assert created_note.get("title") == note_title
note_id = created_note.get("id")
assert note_id is not None
logger.info(f"OAuth client successfully created note with ID: {note_id}")
# Clean up - delete the note
try:
await nc_oauth_client.notes.delete_note(note_id=note_id)
logger.info(f"OAuth client successfully deleted note {note_id}")
except Exception as e:
logger.error(f"Failed to clean up test note {note_id}: {e}")
raise
# OAuth Token Validation Tests
async def test_token_in_request_headers(
nc_oauth_client: NextcloudClient, playwright_oauth_token: str
):
"""Verify that bearer token is being used in requests."""
# The client should be using BearerAuth
assert nc_oauth_client._client.auth is not None
# Make a request and verify it works
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
logger.info("OAuth bearer token is correctly included in requests")
async def test_invalid_token_fails():
"""Test that an invalid token results in authentication failure."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("NEXTCLOUD_HOST not set")
# Create client with invalid token using BearerAuth
invalid_client = NextcloudClient(
base_url=nextcloud_host,
username="testuser",
auth=BearerAuth("invalid_token_12345"),
)
# Attempt to use a protected endpoint - should fail with 401
# Note: capabilities endpoint is public and doesn't require auth
with pytest.raises(HTTPStatusError) as exc_info:
_ = [note async for note in invalid_client.notes.get_all_notes()]
assert exc_info.value.response.status_code == 401
await invalid_client.close()
logger.info("Invalid OAuth token correctly rejected")
+32
View File
@@ -0,0 +1,32 @@
"""Integration tests for Playwright-based OAuth authentication."""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
"""Test that Playwright can acquire an OAuth token automatically."""
assert playwright_oauth_token is not None
assert isinstance(playwright_oauth_token, str)
assert len(playwright_oauth_token) > 0
logger.info(
f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..."
)
async def test_oauth_client_with_playwright_flow(nc_oauth_client):
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
# Test 1: Check capabilities
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
logger.info("OAuth client (Playwright) successfully fetched capabilities")
# Test 2: List notes
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
assert isinstance(notes, list)
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
+172
View File
@@ -0,0 +1,172 @@
"""Integration tests for Nextcloud Sharing API client."""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.mark.anyio
async def test_create_and_delete_share(nc_client):
"""Test creating and deleting a file share."""
# Create a test user to share with
test_user = "testuser3"
try:
await nc_client.users.create_user(
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
)
except Exception:
pass # User might already exist
# Create a test file
file_path = "/test_share_file.txt"
file_content = b"Test file for sharing"
await nc_client.webdav.write_file(file_path, file_content)
share_id = None
try:
# Create a share
share_data = await nc_client.sharing.create_share(
path=file_path,
share_with=test_user, # Share with test user
share_type=0, # User share
permissions=1, # Read-only
)
assert share_data is not None
assert "id" in share_data
share_id = share_data["id"]
logger.info(f"Created share: {share_id}")
# Get share info
share_info = await nc_client.sharing.get_share(share_id)
assert share_info["id"] == share_id
assert share_info["path"] == file_path
assert share_info["permissions"] == 1
# List shares
shares = await nc_client.sharing.list_shares(path=file_path)
assert len(shares) > 0
assert any(s["id"] == share_id for s in shares)
finally:
# Cleanup
if share_id:
await nc_client.sharing.delete_share(share_id)
logger.info(f"Deleted share: {share_id}")
await nc_client.webdav.delete_resource(file_path)
# Cleanup test user
try:
await nc_client.users.delete_user(test_user)
except Exception:
pass
@pytest.mark.anyio
async def test_update_share_permissions(nc_client):
"""Test updating share permissions."""
# Create a test user to share with
test_user = "testuser3"
try:
await nc_client.users.create_user(
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
)
except Exception:
pass # User might already exist
# Create a test file
file_path = "/test_share_update.txt"
file_content = b"Test file for permission updates"
await nc_client.webdav.write_file(file_path, file_content)
share_id = None
try:
# Create a share with read-only permissions
share_data = await nc_client.sharing.create_share(
path=file_path,
share_with=test_user,
share_type=0,
permissions=1, # Read-only
)
share_id = share_data["id"]
# Update to read+write permissions
updated_share = await nc_client.sharing.update_share(
share_id=share_id,
permissions=3, # Read + Write
)
assert updated_share["id"] == share_id
assert updated_share["permissions"] == 3
finally:
# Cleanup
if share_id:
await nc_client.sharing.delete_share(share_id)
await nc_client.webdav.delete_resource(file_path)
# Cleanup test user
try:
await nc_client.users.delete_user(test_user)
except Exception:
pass
@pytest.mark.anyio
async def test_list_shares(nc_client):
"""Test listing all shares."""
# Create a test user to share with
test_user = "testuser3"
try:
await nc_client.users.create_user(
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
)
except Exception:
pass # User might already exist
# Create a test file
file_path = "/test_list_shares.txt"
file_content = b"Test file for listing shares"
await nc_client.webdav.write_file(file_path, file_content)
share_id = None
try:
# Create a share
share_data = await nc_client.sharing.create_share(
path=file_path,
share_with=test_user,
share_type=0,
permissions=1,
)
share_id = share_data["id"]
# List all shares
all_shares = await nc_client.sharing.list_shares()
assert len(all_shares) > 0
# List shares for specific file
file_shares = await nc_client.sharing.list_shares(path=file_path)
assert len(file_shares) > 0
assert any(s["id"] == share_id for s in file_shares)
finally:
# Cleanup
if share_id:
await nc_client.sharing.delete_share(share_id)
await nc_client.webdav.delete_resource(file_path)
# Cleanup test user
try:
await nc_client.users.delete_user(test_user)
except Exception:
pass
+264
View File
@@ -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}")

Some files were not shown because too many files have changed in this diff Show More