Compare commits

...

151 Commits

Author SHA1 Message Date
github-actions[bot] 50a824155c bump: version 0.19.0 → 0.19.1 2025-10-24 04:36:51 +00:00
Chris Coutinho 0df9e41332 Merge pull request #238 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.19,<1.20
2025-10-24 06:36:20 +02:00
renovate-bot-cbcoutinho[bot] 3baf10662f fix(deps): update dependency mcp to >=1.19,<1.20 2025-10-24 04:06:55 +00:00
github-actions[bot] bfbaed9a66 bump: version 0.18.0 → 0.19.0 2025-10-23 23:50:51 +00:00
Chris Coutinho ff32149220 Merge pull request #235 from cbcoutinho/feature/opaque-introspection
Feature/opaque introspection
2025-10-24 01:50:17 +02:00
Chris Coutinho d55e5708c7 ci: fix imports 2025-10-24 01:04:30 +02:00
Chris Coutinho d4ee5a74c2 test: Update default tokens to JWT, add to introspection tests 2025-10-24 00:51:50 +02:00
Chris Coutinho 261749fcdc ci: Update oidc app 2025-10-23 22:45:22 +02:00
Chris Coutinho bdb0e17401 chore: Add logging to token introspection 2025-10-23 21:18:14 +02:00
Chris Coutinho 8942f3119c Merge pull request #236 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin shivammathur/setup-php action to bf6b4fb
2025-10-23 18:51:05 +02:00
renovate-bot-cbcoutinho[bot] 3863cca2ed chore(deps): pin shivammathur/setup-php action to bf6b4fb 2025-10-23 16:05:50 +00:00
Chris Coutinho a93e7a1e3b build: Update submodule 2025-10-23 16:56:18 +02:00
Chris Coutinho f2d2dd8068 feat: Enable token introspection for opaque tokens 2025-10-23 15:51:27 +02:00
Chris Coutinho d915efd3f6 docs: Update jwt docs [skip ci] 2025-10-23 15:26:51 +02:00
Chris Coutinho 053cf7798b fix: Add CORS middleware to allow browser-based clients like MCP Inspector 2025-10-23 15:23:41 +02:00
github-actions[bot] 87c6f077f3 bump: version 0.17.1 → 0.18.0 2025-10-23 10:23:48 +00:00
Chris Coutinho 38e12db46a Merge pull request #233 from cbcoutinho/feature/jwt-scopes
feat: Initialize JWT-scoped tools
2025-10-23 12:23:12 +02:00
Chris Coutinho 1a7ce5b7a7 docs: Update jwt docs [skip ci] 2025-10-23 12:22:34 +02:00
Chris Coutinho 737780b417 chore: Make all env vars available to be overriden as cli options 2025-10-23 11:48:01 +02:00
Chris Coutinho b4039e2e40 docs: Update jwt docs 2025-10-23 11:20:49 +02:00
Chris Coutinho 54e975198f test: Update all test network hosts to respect iss claims from JWTs 2025-10-23 11:09:51 +02:00
Chris Coutinho e9a16c43b5 refactor: Update JWT client to use DCR, re-enable tool filtering 2025-10-23 09:33:06 +02:00
Chris Coutinho e48f5f3f30 feat(server): Add support for custom OIDC scopes and permissions via JWTs 2025-10-23 08:37:36 +02:00
Chris Coutinho 3ebc468a09 ci: Tasks has been updated, no longer a debug app 2025-10-23 07:53:52 +02:00
Chris Coutinho 1aecb099e6 fix: Use occ-created OAuth clients with allowed_scopes for all tests
The shared_oauth_client_credentials fixture was using Dynamic Client
Registration which doesn't support Nextcloud's allowed_scopes parameter.
This caused tokens to lack proper scope configuration, resulting in empty
tool lists when the server validated scopes.

Changes:
1. Updated shared_oauth_client_credentials to use occ oidc:create with
   allowed_scopes="openid profile email nc:read nc:write"
2. Created opaque token client (not JWT) for port 8001 compatibility
3. Enhanced _create_oauth_client_with_scopes to support both JWT and
   opaque token types via token_type parameter

This ensures:
- Regular OAuth tests (port 8001) get opaque tokens with proper scopes
- JWT OAuth tests (port 8002) get JWT tokens with embedded scopes
- Both token types have allowed_scopes configured on the OAuth client

Fixes test_mcp_oauth_server_connection which was getting empty tool list

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 07:38:16 +02:00
Chris Coutinho 2c35e07675 fix: Separate OAuth fixtures for opaque vs JWT tokens
Previous fix created a JWT OAuth client for all tests, which broke the
regular OAuth server (port 8001) that expects opaque tokens.

This commit:
1. Reverts shared_oauth_client_credentials to use regular OAuth (opaque tokens)
2. Creates new shared_jwt_oauth_client_credentials for JWT OAuth clients
3. Creates new playwright_oauth_token_jwt fixture using JWT credentials
4. Updates nc_mcp_oauth_jwt_client to use JWT token fixture

This ensures:
- Regular OAuth tests (port 8001) use opaque tokens
- JWT OAuth tests (port 8002) use JWT tokens with embedded scopes

Fixes remaining CI failure in test_mcp_oauth_server_connection
2025-10-22 07:17:43 +02:00
Chris Coutinho 5cfdff0faf test: Create JWT OAuth client with explicit scopes for shared test fixture
The shared_oauth_client_credentials fixture was creating an OAuth client
without explicit allowed_scopes configuration. This caused JWT tokens to
lack nc:read and nc:write scope claims, resulting in the JWT MCP server
filtering out ALL tools when list_tools() was called.

Changed the fixture to use _create_oauth_client_with_scopes() helper to
create a JWT client with explicit allowed_scopes="openid profile email
nc:read nc:write", matching the scopes requested in the authorization
URL and the behavior of other scoped test fixtures.

This fixes CI test failures in:
- test_mcp_oauth.py::test_mcp_oauth_server_connection
- test_mcp_oauth_jwt.py::test_jwt_mcp_server_connection
- test_mcp_oauth_jwt.py::test_jwt_tool_list_operations
- test_mcp_oauth_jwt.py::test_jwt_automation_worked

All were failing with: assert len(result.tools) > 0 (result.tools was empty)
2025-10-22 07:02:40 +02:00
Chris Coutinho eb7e15cac0 Merge pull request #232 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f9bec5c
2025-10-22 06:42:22 +02:00
Chris Coutinho 894723c525 ci: Add missing files 2025-10-22 06:40:11 +02:00
Chris Coutinho 8a3269f366 test: Use separate docker compose command 2025-10-22 06:38:05 +02:00
Chris Coutinho c069d78f80 feat: Initialize JWT-scoped tools 2025-10-22 06:21:16 +02:00
renovate-bot-cbcoutinho[bot] e3436fecc0 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f9bec5c 2025-10-22 04:06:24 +00:00
Chris Coutinho e3feb3eb2f Merge pull request #231 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.5
2025-10-22 03:59:07 +02:00
renovate-bot-cbcoutinho[bot] eedaa2e3f1 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.5 2025-10-21 22:09:23 +00:00
Chris Coutinho d517fe09d8 Merge pull request #230 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to d3d8b9d
2025-10-21 23:24:50 +02:00
renovate-bot-cbcoutinho[bot] 08ebab9f48 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to d3d8b9d 2025-10-21 16:06:08 +00:00
Chris Coutinho f4f9548681 Merge pull request #229 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 4fbd72f
2025-10-21 13:45:08 +02:00
renovate-bot-cbcoutinho[bot] 27bb0a4b56 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 4fbd72f 2025-10-21 10:06:57 +00:00
Chris Coutinho 7f5828390c docs: Update README 2025-10-21 11:47:01 +02:00
Chris Coutinho 8ad1937347 docs: Update README 2025-10-21 11:26:11 +02:00
Chris Coutinho 0d29048155 Merge pull request #228 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7
2025-10-21 00:10:27 +02:00
Chris Coutinho 499429706c Merge branch 'master' into renovate/astral-sh-setup-uv-7.x 2025-10-21 00:09:50 +02:00
Chris Coutinho 2903094d67 Merge pull request #227 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-10-21 00:09:12 +02:00
renovate-bot-cbcoutinho[bot] 7abfa19d15 chore(deps): update astral-sh/setup-uv action to v7 2025-10-20 22:06:35 +00:00
renovate-bot-cbcoutinho[bot] c109626601 chore(deps): pin dependencies 2025-10-20 22:06:30 +00:00
Chris Coutinho a5a4e809c4 ci: Add smoke test during release 2025-10-20 23:39:47 +02:00
github-actions[bot] 4984496d81 bump: version 0.17.0 → 0.17.1 2025-10-20 21:16:09 +00:00
Chris Coutinho 0e79ba06a9 Merge pull request #226 from cbcoutinho/feature/docs
Feature/docs
2025-10-20 23:15:20 +02:00
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 961f23b5ea feat(users): Initialize user API client 2025-09-11 09:42:42 +02:00
104 changed files with 20753 additions and 2134 deletions
+33
View File
@@ -0,0 +1,33 @@
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
run: uv build
- name: Smoke test (wheel)
run: uv run --isolated --no-project --with dist/*.whl nextcloud-mcp-server --help
- name: Smoke test (source distribution)
run: uv run --isolated --no-project --with dist/*.tar.gz nextcloud-mcp-server --help
- name: Publish
run: uv publish
+23 -4
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -25,6 +25,25 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
submodules: 'true'
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
with:
php-version: 8.4
coverage: none
- name: Install OIDC app composer dependencies
run: |
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
- name: Run docker compose
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
@@ -33,11 +52,11 @@ jobs:
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- name: Install Playwright dependencies
run: |
uv run playwright install firefox --with-deps
uv run playwright install chromium --with-deps
- name: Wait for service to be ready
run: |
@@ -62,4 +81,4 @@ jobs:
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
uv run pytest -v --browser firefox
uv run pytest -v --log-cli-level=INFO
+3 -1
View File
@@ -4,4 +4,6 @@ __pycache__/
*.env
.env.local
.env.*.local
.nextcloud_oauth_test_client.json
# Generated by pytest used to login users
.nextcloud_oauth_*.json
+6
View File
@@ -0,0 +1,6 @@
[submodule "oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
[submodule "third_party/oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
+112
View File
@@ -1,3 +1,115 @@
## v0.19.1 (2025-10-24)
### Fix
- **deps**: update dependency mcp to >=1.19,<1.20
## v0.19.0 (2025-10-23)
### Feat
- Enable token introspection for opaque tokens
### Fix
- Add CORS middleware to allow browser-based clients like MCP Inspector
## v0.18.0 (2025-10-23)
### Feat
- **server**: Add support for custom OIDC scopes and permissions via JWTs
- Initialize JWT-scoped tools
### Fix
- Use occ-created OAuth clients with allowed_scopes for all tests
- Separate OAuth fixtures for opaque vs JWT tokens
### Refactor
- Update JWT client to use DCR, re-enable tool filtering
## v0.17.1 (2025-10-20)
### Fix
- **caldav**: Fix caldav search() due to missing todos
## 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
+139 -42
View File
@@ -19,6 +19,53 @@ uv run pytest --cov
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
@@ -38,13 +85,21 @@ mcp run --transport sse nextcloud_mcp_server.app:mcp
# Docker development environment with Nextcloud instance
docker-compose up
# After code changes, rebuild and restart only the MCP server container
# 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
@@ -81,7 +136,17 @@ Each Nextcloud app has a corresponding server module that:
### Supported Nextcloud Apps
- **Notes** - Full CRUD operations and search
- **Calendar** - CalDAV integration with events, recurring events, attendees
- **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
@@ -94,72 +159,92 @@ Each Nextcloud app has a corresponding server module that:
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/integration/` - Test real Nextcloud API interactions
- **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 to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
- **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
- Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes
- **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
- `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/integration/test_mcp.py -k "notes" -v`
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
- 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 support both **automated** (Playwright) and **interactive** authentication flows:
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
**Automated Testing (Default - Recommended for CI/CD):**
- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default
- Uses Playwright headless browser automation to complete OAuth flow programmatically
- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright`
- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
**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:
```bash
# Run all OAuth tests with automated Playwright flow using Firefox
uv run pytest tests/integration/test_oauth*.py --browser firefox -v
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
# Run specific Playwright tests with visible browser for debugging
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox --headed -v
**Example Commands:**
```bash
# Run all OAuth tests with Playwright automation using Firefox
uv run pytest tests/server/test_oauth*.py --browser firefox -v
# Run with Chromium (default)
uv run pytest tests/integration/test_oauth.py -v
```
# Run specific tests with visible browser for debugging
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
**Interactive Testing (Manual browser login):**
- Opens system browser and waits for manual login/authorization
- Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive`
- Requires: User to complete browser-based login when prompted
- Useful for: Debugging OAuth flows, testing with 2FA, local development
- **Automatically skipped in GitHub Actions CI** - Interactive fixtures check for `GITHUB_ACTIONS` environment variable
- Example:
```bash
# Run OAuth tests with interactive flow (will open browser and wait for manual login)
uv run pytest tests/integration/test_oauth_interactive.py -v
```
# Run with Chromium (default)
uv run pytest tests/server/test_oauth*.py -v
```
**Test Environment Setup:**
**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`
- OAuth server runs on port 8001 (regular MCP on 8000)
- Both flows register OAuth clients dynamically using Nextcloud's OIDC provider
- **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 Considerations:**
- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set
- Automated Playwright tests will run in CI/CD environments
**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
@@ -167,3 +252,15 @@ OAuth integration tests support both **automated** (Playwright) and **interactiv
- **`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
## Integration testing with docker
### Nextcloud
- The `app` container is running nextcloud.
- Use `docker compose exec app php occ ...` to get a list of available commands
### Mariadb
- The `db` container is running mariadb
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
+4 -1
View File
@@ -1,4 +1,7 @@
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
# Install git (required for caldav dependency from git)
RUN apk add --no-cache git
WORKDIR /app
+79 -31
View File
@@ -6,19 +6,36 @@
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
## Features
> [!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](https://docs.nextcloud.com/server/stable/admin_manual/ai/app_context_agent.html#using-nextcloud-mcp-server)_ for external MCP clients.
>
> 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. It does not require any additional AI-features to be enabled in Nextcloud beyond the apps that you intend to interact with.
### Supported Nextcloud Apps
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
| App | Support | Features |
|-----|---------|----------|
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
| 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 |
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
@@ -26,8 +43,17 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
| Mode | Security | Best For |
|------|----------|----------|
| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments |
| **Basic Auth** ⚠️ | Lower | Development, testing |
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
| **Basic Auth** | Lower | Development, testing, production |
> [!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.
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
@@ -58,29 +84,35 @@ Create a `.env` file:
cp env.sample .env
```
**For OAuth (recommended):**
```dotenv
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
```
**For Basic Auth:**
**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
**OAuth Setup (recommended):**
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
2. Enable dynamic client registration
3. Configure Bearer token validation
4. Start the server
**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
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment.
**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
@@ -88,12 +120,15 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Start the server
# 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 --oauth
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
The server starts on `http://127.0.0.1:8000` by default.
@@ -120,12 +155,15 @@ Or connect from:
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
- **[Running the Server](docs/running.md)** - Start and manage the server
### OAuth Documentation
### 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)** - Production deployment
- **[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
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
### Reference
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
@@ -134,6 +172,7 @@ Or connect from:
- [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)
@@ -145,6 +184,7 @@ The server exposes Nextcloud functionality through MCP tools (for actions) and r
### 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
@@ -153,6 +193,7 @@ Tools enable AI assistants to perform actions:
### 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...
@@ -167,6 +208,12 @@ 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"
@@ -214,7 +261,8 @@ Contributions are welcome!
[![MseeP.ai Security Assessment](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
This project takes security seriously:
- OAuth2/OIDC support for secure authentication
- 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
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
@@ -1,16 +0,0 @@
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
index ee3cd57..6429f94 100644
--- a/lib/Util/DiscoveryGenerator.php
+++ b/lib/Util/DiscoveryGenerator.php
@@ -171,6 +171,11 @@ class DiscoveryGenerator
$discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []);
}
+ // Add PKCE support if enabled
+ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
+ }
+
$this->logger->info('Request to Discovery Endpoint.');
$response = new JSONResponse($discoveryPayload);
@@ -6,14 +6,18 @@ echo "Installing and configuring Calendar app..."
# Enable calendar app
php /var/www/html/occ app:enable calendar
php /var/www/html/occ app:enable tasks
# Wait for calendar app to be fully initialized
echo "Waiting for calendar app to initialize..."
sleep 5
# Increase limits on calendar creation for integration tests (100 in 60s)
# 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
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable cookbook
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring OIDC app for testing..."
# Check if development OIDC app is mounted at /opt/apps/oidc
if [ -d /opt/apps/oidc ]; then
echo "Development OIDC app found at /opt/apps/oidc"
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/oidc ]; then
echo "Removing existing OIDC in custom_apps..."
rm -rf /var/www/html/custom_apps/oidc
fi
# Create symlink from custom_apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable oidc
elif [ -d /var/www/html/custom_apps/oidc ]; then
echo "OIDC app directory found in custom_apps (already installed)"
php /var/www/html/occ app:enable oidc
else
echo "OIDC app not found, installing from app store..."
php /var/www/html/occ app:install oidc
php /var/www/html/occ app:enable oidc
fi
# 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
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
echo "OIDC app installed and configured successfully"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring user_oidc app for testing..."
# Enable the user_oidc app (OIDC client for bearer token validation)
php /var/www/html/occ app:enable user_oidc
# 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
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
@@ -1,23 +0,0 @@
#!/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
patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch
# Configure OIDC Identity Provider with dynamic client registration enabled
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
echo "OIDC apps installed and configured successfully"
+39 -4
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4
image: docker.io/library/nextcloud:32.0.0@sha256:f9bec5c77a8d5603354b990550a4d24487deae6e589dd20ce870e43e28460e18
restart: always
ports:
- 0.0.0.0:8080:80
@@ -31,6 +31,9 @@ services:
volumes:
- nextcloud:/var/www/html
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
- ./third_party/oidc:/opt/apps/oidc:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -39,6 +42,14 @@ 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: .
@@ -63,13 +74,37 @@ services:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
# No USERNAME/PASSWORD - will use OAuth
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
# Client credentials will be registered and stored in volume on first startup
volumes:
- oauth-client-storage:/app/.oauth
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
restart: always
depends_on:
- app
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration (DCR)
# Client will be registered with token_type=JWT on first startup
volumes:
- oauth-jwt-client-storage:/app/.oauth-jwt
volumes:
nextcloud:
db:
oauth-client-storage:
oauth-jwt-client-storage:
+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)
+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).
+901
View File
@@ -0,0 +1,901 @@
# JWT OAuth Reference - Nextcloud MCP Server
**Last Updated:** 2025-10-23
**Status:** Production Ready
## Table of Contents
- [Overview](#overview)
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
- [Scope-Based Authorization](#scope-based-authorization)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Production Deployment](#production-deployment)
---
## Overview
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
- **Faster validation** - No HTTP call needed for token verification
- **Direct scope extraction** - Scopes embedded in token claims
- **Dynamic tool filtering** - Users only see tools they have permission to use
- **Signature verification** - Cryptographic validation using JWKS
### Key Features
-**JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
-**Custom Scopes** - `nc:read` and `nc:write` for read/write access control
-**Dynamic Tool Filtering** - Tools filtered based on user's token scopes
-**Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
-**Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
-**Backward Compatible** - BasicAuth mode bypasses all scope checks
### Supported Scopes
| Scope | Description | Tool Count |
|-------|-------------|------------|
| `nc:read` | Read-only access to Nextcloud data | 36 tools |
| `nc:write` | Write access to create/modify/delete data | 54 tools |
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
---
## JWT vs Opaque Tokens
The Nextcloud OIDC app supports two token formats, configured per-client:
### JWT Tokens (Recommended)
**Advantages:**
- ✅ Fast validation - JWT signature verified locally using JWKS
- ✅ Direct scope extraction from `scope` claim in payload
- ✅ Standard approach (RFC 9068)
- ✅ No additional HTTP calls for validation
**Disadvantages:**
- ⚠️ Larger size (~800-1200 chars vs 72 chars for opaque)
- ⚠️ Token payload visible to client (not an issue for access tokens)
**Token Structure:**
```json
{
"header": {
"typ": "at+JWT",
"alg": "RS256",
"kid": "..."
},
"payload": {
"iss": "http://localhost:8080",
"sub": "admin",
"aud": "client_id",
"exp": 1234567890,
"iat": 1234567890,
"scope": "openid profile email nc:read nc:write",
"client_id": "...",
"jti": "..."
}
}
```
### Opaque Tokens
**Advantages:**
- ✅ Smaller size (72 characters)
- ✅ No payload visible to client
- ✅ Direct scope access via introspection endpoint (RFC 7662)
**Disadvantages:**
- ❌ Higher latency - Requires HTTP call to introspection endpoint
- ❌ Slower than JWT signature verification (network roundtrip)
**Validation Method:**
Opaque tokens are validated using the **introspection endpoint** (`/apps/oidc/introspect`), which returns:
- Token active status
- Scope claim (direct access, no inference needed)
- User information (`sub`, `username`)
- Token metadata (`exp`, `iat`, `client_id`)
Falls back to userinfo endpoint only if introspection is unavailable.
**When to Use:**
- Use **JWT tokens** for production (better performance, no HTTP call)
- Use **opaque tokens** for compatibility with clients that don't support JWT
---
## Scope-Based Authorization
### Scope Definitions
The MCP server uses **coarse-grained scopes** for simplicity:
| Scope | Operations | Examples |
|-------|------------|----------|
| `nc:read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `nc:write` | Write operations | Create notes, update events, delete files, modify contacts |
### Standard OIDC Scopes
| Scope | Description | Required |
|-------|-------------|----------|
| `openid` | OIDC authentication | Yes |
| `profile` | User profile information | Recommended |
| `email` | Email address | Recommended |
### Recommended Configurations
**Full Access:**
```
openid profile email nc:read nc:write
```
**Read-Only:**
```
openid profile email nc:read
```
**No Custom Scopes (OIDC only):**
```
openid profile email
```
### Implementation
All 90 MCP tools are decorated with scope requirements:
```python
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(note_id: int, ctx: Context):
"""Get a note by ID (requires nc:read scope)"""
...
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(title: str, content: str, ctx: Context):
"""Create a note (requires nc:write scope)"""
...
```
**Coverage:**
- ✅ 36 read tools decorated with `@require_scopes("nc:read")`
- ✅ 54 write tools decorated with `@require_scopes("nc:write")`
- ✅ 90/90 tools covered (100%)
### Dynamic Tool Filtering
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
**Token with `nc:read` only:**
- `list_tools()` returns 36 read-only tools
- Write tools are hidden from the tool list
**Token with `nc:write` only:**
- `list_tools()` returns 54 write-only tools
- Read tools are hidden from the tool list
**Token with both scopes:**
- `list_tools()` returns all 90 tools
**Token with no custom scopes:**
- `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`)
**BasicAuth mode:**
- `list_tools()` returns all 90 tools (no filtering)
**Note:** JWT tokens include scopes in the token payload, while Bearer tokens retrieve scopes via the introspection endpoint. Both methods provide reliable scope information for filtering.
### Scope Challenges
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="nc:write",
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
```
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
### Protected Resource Metadata (PRM)
The server implements RFC 9728's Protected Resource Metadata endpoint:
**Endpoint:** `GET /.well-known/oauth-protected-resource/mcp`
**Response:**
```json
{
"resource": "http://localhost:8002/mcp",
"scopes_supported": ["nc:read", "nc:write"],
"authorization_servers": ["http://localhost:8080"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
}
```
This allows OAuth clients to discover supported scopes before requesting authorization.
---
## Configuration
### Docker Services
The development environment includes three MCP server variants:
| Service | Port | Auth Type | Token Type | Use Case |
|---------|------|-----------|------------|----------|
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
| `mcp-oauth` | 8001 | OAuth | Opaque | Standard OAuth flows |
| `mcp-oauth-jwt` | 8002 | OAuth | JWT | Production, JWT testing |
### JWT Service Configuration
The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default:
**Default Configuration (DCR):**
```yaml
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
volumes:
- ./oauth-storage:/app/.oauth # Optional: persist DCR credentials
```
**With Pre-Configured Credentials:**
```yaml
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
```
**Key Points:**
- **No credentials needed** - DCR automatically registers the client on first start
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
- **JWT tokens** - Set `TOKEN_TYPE=jwt` for better performance
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXTCLOUD_HOST` | Nextcloud base URL | `http://localhost:8080` |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server external URL for OAuth callbacks | (required in OAuth mode) |
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email nc:read nc:write"` |
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
### Dynamic Client Registration (DCR)
The MCP server supports **automatic OAuth client registration** using the OIDC Discovery registration endpoint. This eliminates the need for manual client creation in most cases.
**How It Works:**
When the MCP server starts in OAuth mode, it follows this **three-tier credential loading strategy**:
```
1. Environment Variables (Highest Priority)
├─ NEXTCLOUD_OIDC_CLIENT_ID
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
2. Storage File (Second Priority)
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
3. Dynamic Client Registration (Automatic Fallback)
├─ Discovers registration endpoint from /.well-known/openid-configuration
├─ Registers new client with requested scopes and token type
├─ Saves credentials to storage file for future use
└─ Client credentials persist across restarts
```
**Configuration:**
DCR automatically configures the client based on environment variables:
```bash
# Minimal DCR configuration (no credentials needed!)
export NEXTCLOUD_HOST=http://localhost:8080
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write"
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
```
**Credential Storage:**
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
- File has restrictive permissions (0600 - owner read/write only)
- Credentials are reused on subsequent starts (no re-registration needed)
- Storage file is checked for expiration (auto-regenerates if expired)
**Format:**
```json
{
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"client_id_issued_at": 1761097039,
"client_secret_expires_at": 2076457039,
"redirect_uris": ["http://localhost:8000/oauth/callback"]
}
```
**Benefits:**
- ✅ Zero-configuration OAuth setup
- ✅ Automatic credential management
- ✅ Supports both JWT and opaque tokens
- ✅ Credentials persist across container restarts
- ✅ Automatic re-registration if credentials expire
- ✅ Properly sets `allowed_scopes` for JWT token validation
### Manual Client Creation
Manual client creation is **optional** but may be preferred when:
- You want explicit control over client configuration
- You're deploying to production environments with strict security policies
- You need to pre-provision OAuth clients before deployment
**Create Client via OCC Command:**
```bash
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email nc:read nc:write" \
"Nextcloud MCP Server" \
"http://localhost:8000/oauth/callback"
```
**Output:**
```json
{
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"token_type": "jwt",
"allowed_scopes": "openid profile email nc:read nc:write"
}
```
**Configure MCP Server with Pre-Configured Credentials:**
```bash
# Option 1: Environment variables (highest priority)
export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
# Option 2: Storage file (second priority)
# Save the JSON response to .nextcloud_oauth_client.json
# Server will automatically load it on startup
```
When credentials are provided via environment variables or storage file, **DCR is skipped**.
---
## Architecture
### Component Overview
```
┌──────────────────┐ OAuth Flow ┌──────────────────┐
│ OAuth Client │<─────────────────────>│ Nextcloud OIDC │
│ (Claude, etc) │ │ Server │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ JWT Access Token │
│ { │
│ "scope": "openid nc:read nc:write" │
│ ... │
│ } │
│ │
v │
┌────────────────────────────────────────────────────────────┐
│ Nextcloud MCP Server │
│ ┌───────────────────────────────────────────────────┐ │
│ │ NextcloudTokenVerifier │ │
│ │ - JWT signature verification (JWKS) │ │
│ │ - Introspection endpoint (opaque tokens) │ │
│ │ - Userinfo fallback (last resort) │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ v │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Dynamic Tool Filtering (list_tools) │ │
│ │ - Get user scopes from verified token │ │
│ │ - Filter tools based on @require_scopes metadata │ │
│ │ - Return only accessible tools │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ v │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Tool Execution (@require_scopes decorator) │ │
│ │ - Check token scopes before execution │ │
│ │ - Raise InsufficientScopeError if missing │ │
│ │ - Return 403 with WWW-Authenticate header │ │
│ └───────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
### Key Components
**1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`)
- **Three-tier validation strategy:**
1. **JWT verification** (lines 116-124): JWKS signature validation for JWT tokens
2. **Introspection** (lines 126-134): RFC 7662 endpoint for opaque tokens
3. **Userinfo fallback** (lines 137-142): Last resort if introspection unavailable
- Scope extraction from token payload (JWT) or introspection response (opaque)
- Token caching with TTL to reduce repeated validations
- Supports both access token formats transparently
**2. Scope Authorization** (`nextcloud_mcp_server/auth/scope_authorization.py`)
- `@require_scopes()` decorator for tools
- `get_required_scopes()` - Extract scope requirements from functions
- `has_required_scopes()` - Check if user has necessary scopes
- `InsufficientScopeError` exception for WWW-Authenticate challenges
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:473-516`)
- Overrides FastMCP's `list_tools()` method
- Filters based on user's OAuth token scopes (JWT and Bearer)
- Only active in OAuth mode
- Bypassed in BasicAuth mode
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
- `GET /.well-known/oauth-protected-resource/mcp`
- Advertises `["nc:read", "nc:write"]`
- RFC 9728 compliant
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
- Catches `InsufficientScopeError`
- Returns 403 with `WWW-Authenticate` header
- Includes missing scopes and PRM endpoint URL
### Token Validation Flow
The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently:
```
┌─────────────────────────────────────────────────────────┐
│ verify_token(token) │
│ (nextcloud_mcp_server/auth/token_verifier.py:88-142) │
└────────────────────────┬────────────────────────────────┘
├──> 1. Check cache (lines 106-109)
│ ├─ Hit: Return cached AccessToken
│ └─ Miss: Continue to validation
├──> 2. JWT Format Check (lines 112-124)
│ ├─ Token has 3 parts (header.payload.signature)?
│ │ └─ Yes: Attempt JWT verification
│ │ ├─ Verify signature with JWKS (RS256)
│ │ ├─ Validate issuer, expiration
│ │ ├─ Extract scopes from payload
│ │ └─ Success: Return AccessToken
│ └─ Fail/Not JWT: Continue to introspection
├──> 3. Introspection (lines 126-134)
│ ├─ POST to /apps/oidc/introspect
│ ├─ Authenticate with client credentials
│ ├─ Response contains:
│ │ • active: true/false
│ │ • scope: "openid nc:read nc:write"
│ │ • sub, exp, iat, client_id
│ ├─ Extract scopes from response
│ └─ Success: Return AccessToken
└──> 4. Userinfo Fallback (lines 137-142)
├─ GET /apps/oidc/userinfo
├─ Bearer token in Authorization header
├─ Infer scopes from response claims
└─ Return AccessToken or None
```
**Validation Priorities:**
| Token Type | Method | Performance | Scope Access | Code Reference |
|------------|--------|-------------|--------------|----------------|
| JWT | JWKS Signature | ⚡ Fastest (local) | Direct (`scope` claim) | `token_verifier.py:156-234` |
| Opaque | Introspection | 🔄 Medium (HTTP) | Direct (`scope` field) | `token_verifier.py:236-328` |
| Any | Userinfo | 🐌 Slowest (HTTP + inference) | Inferred (from claims) | `token_verifier.py:330-386` |
**Configuration** (`nextcloud_mcp_server/app.py:391-399`):
```python
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enables JWT verification
issuer=jwt_validation_issuer, # For JWT issuer validation
introspection_uri=introspection_uri, # Enables introspection for opaque tokens
client_id=client_id, # Required for introspection auth
client_secret=client_secret, # Required for introspection auth
)
```
## Testing
### Test Infrastructure
The test suite includes comprehensive coverage for JWT OAuth and scope authorization:
**Test Files:**
- `tests/server/test_scope_authorization.py` - Scope-based authorization tests (4 tests)
- `tests/server/test_mcp_oauth_jwt.py` - JWT OAuth integration tests
- `tests/conftest.py` - Shared fixtures for JWT testing
### Consent Scenario Tests
Four test scenarios verify scope-based tool filtering with different consent levels:
#### 1. No Custom Scopes (0 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_scopes_returns_zero_tools -v
```
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
**Expected:** 0 tools returned (all require `nc:read` or `nc:write`)
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
#### 2. Read-Only Access (36 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
```
**Scenario:** JWT token with `nc:read` only
**Expected:** 36 read-only tools visible, write tools hidden
**Verifies:** Read tools accessible, write tools filtered out
#### 3. Write-Only Access (54 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
```
**Scenario:** JWT token with `nc:write` only
**Expected:** 54 write tools visible, read tools hidden
**Verifies:** Write tools accessible, read tools filtered out
#### 4. Full Access (90 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
```
**Scenario:** JWT token with both `nc:read` and `nc:write`
**Expected:** All 90 tools visible
**Verifies:** Full access when user grants all custom scopes
### Test Fixtures
**OAuth Client Fixtures:**
- `read_only_oauth_client_credentials` - Client with `nc:read` only
- `write_only_oauth_client_credentials` - Client with `nc:write` only
- `full_access_oauth_client_credentials` - Client with both scopes
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
**Token Fixtures:**
- `playwright_oauth_token_read_only` - Obtains token with `nc:read`
- `playwright_oauth_token_write_only` - Obtains token with `nc:write`
- `playwright_oauth_token_full_access` - Obtains token with both scopes
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
**MCP Client Fixtures:**
- `nc_mcp_oauth_client_read_only` - MCP session with read-only token
- `nc_mcp_oauth_client_write_only` - MCP session with write-only token
- `nc_mcp_oauth_client_full_access` - MCP session with full access token
- `nc_mcp_oauth_client_no_custom_scopes` - MCP session with no custom scopes
### Running Tests
**All consent scenario tests:**
```bash
uv run pytest tests/server/test_scope_authorization.py -v
```
**JWT OAuth integration tests:**
```bash
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox
```
**With visible browser (debugging):**
```bash
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox --headed
```
### Test Configuration
**Playwright Browser:**
- Default: Chromium
- Recommended for CI: Firefox (`--browser firefox`)
- Debugging: Add `--headed` flag
**OAuth Flow:**
- Uses automated Playwright browser automation
- Completes OAuth consent flow programmatically
- Creates separate OAuth client for each scenario
- Each user gets unique access token
---
## Troubleshooting
### Issue: JWT Issuer Validation Failed
**Symptom:**
```
WARNING JWT issuer validation failed: Invalid issuer
WARNING JWT verification failed, will try other methods
✅ Extracted scopes from access token: {'openid', 'profile'}
```
**Cause:** Token's `iss` claim doesn't match expected issuer URL. This often happens when:
- Using `localhost` vs `127.0.0.1` inconsistently
- MCP server uses internal URL but clients use public URL
**Solution:**
```bash
# Option 1: Use consistent URLs
export NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Ensure all test fixtures also use localhost:8080
# Option 2: Check discovery document
curl http://localhost:8080/.well-known/openid-configuration | jq .issuer
# Use this exact issuer in NEXTCLOUD_PUBLIC_ISSUER_URL
```
**Impact if not fixed:**
- JWT validation falls back to userinfo endpoint
- Scopes inferred from userinfo (only standard OIDC scopes, no custom scopes)
- Result: 0 tools visible or incorrect tool filtering
### Issue: Scopes Not Present in JWT
**Symptom:** JWT token doesn't contain `scope` claim or contains empty string
**Cause:** Client's `allowed_scopes` is empty or not configured
**Solution:**
```bash
# Check client configuration
docker compose exec app php occ oidc:list
# Look for allowed_scopes in output
# If empty, recreate client with --allowed_scopes
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email nc:read nc:write" \
"Client Name" \
"http://callback/url"
```
### Issue: All Tools Visible Despite Read-Only Token
**Symptom:** User with `nc:read` token can see all 90 tools including write tools
**Cause:** Server running in BasicAuth mode, not OAuth mode
**Solution:**
```bash
# Verify OAuth mode is active
docker compose logs mcp-oauth-jwt | grep "OAuth mode"
# Should see: "Running in OAuth mode"
# If not, check environment variables:
docker compose exec mcp-oauth-jwt env | grep NEXTCLOUD_OIDC
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
```
### Verifying DCR Scope Configuration
DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration.
**To verify DCR scopes are working:**
```bash
# Check the registered client's allowed_scopes via database
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
# Should show your requested scopes (e.g., "openid profile email nc:read nc:write")
```
**If scopes are missing:**
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
2. Check MCP server startup logs for the scopes being requested
3. Verify DCR is enabled in Nextcloud OIDC app settings
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
### Issue: Token Type Case Sensitivity
**Symptom:** JWT tokens not generated even though `token_type=JWT` set
**Cause:** OIDC app checks `token_type === 'jwt'` (lowercase)
**Solution:** Always use lowercase:
```bash
# Correct
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
# Incorrect (will generate opaque tokens)
export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
```
### Issue: Missing WWW-Authenticate Header
**Symptom:** 403 error doesn't include `WWW-Authenticate` header
**Cause:** Server not in OAuth mode, or exception not being caught
**Solution:**
```bash
# Check server logs for OAuth mode
docker compose logs mcp-oauth-jwt | grep "WWW-Authenticate scope challenges enabled"
# Should see this during startup
# Check exception handling
docker compose logs mcp-oauth-jwt | grep "InsufficientScopeError"
```
### Debugging Tools
**Check JWT contents:**
```bash
# Decode JWT (base64 decode the payload)
echo "JWT_PAYLOAD_PART" | base64 -d | jq .
```
**Check database scopes:**
```bash
# View access tokens with scopes
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT id, client_id, user_id, scope FROM oc_oidc_access_tokens ORDER BY id DESC LIMIT 5;"
# View user consents
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT user_id, client_id, scopes_granted FROM oc_oidc_user_consents;"
```
**Check server logs:**
```bash
# Follow JWT verification logs
docker compose logs -f mcp-oauth-jwt | grep -E "JWT|scope|tool"
# Check for issuer mismatches
docker compose logs mcp-oauth-jwt | grep -i issuer
```
---
## Production Deployment
### Deployment Checklist
**Use JWT Tokens** - Enable `token_type=jwt` for better performance
**Configure Allowed Scopes** - Always set `allowed_scopes` on OAuth clients
**Use Pre-Configured Clients** - Avoid DCR limitation with manual client creation
**Consistent URLs** - Use same URL for `NEXTCLOUD_HOST` and `PUBLIC_ISSUER_URL`
**Secure Credentials** - Store client credentials securely (environment variables or secrets management)
**Monitor Token Size** - JWT tokens are 10-15x larger than opaque (not usually an issue)
**Enable Logging** - Configure appropriate log levels for JWT verification
### Production Configuration Example
```yaml
# docker-compose.yml (production)
mcp-oauth-jwt:
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
environment:
- NEXTCLOUD_HOST=https://nextcloud.example.com
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
ports:
- "8002:8002"
```
### Security Considerations
**Token Storage:**
- Never commit credentials to version control
- Use environment variables or secrets management
- Rotate client secrets periodically
**Scope Configuration:**
- Grant minimum necessary scopes to clients
- Use read-only tokens for AI assistants that don't need write access
- Review OAuth client list regularly
**Network Security:**
- Use HTTPS in production
- Ensure issuer URL matches public URL
- Configure proper CORS headers
### Monitoring
**Key Metrics:**
- JWT verification success/failure rate
- Scope challenge frequency (indicates clients with insufficient scopes)
- Token validation latency
- Tool execution by scope (identify unused scopes)
**Log Patterns:**
```bash
# Success
INFO JWT verified successfully for user: admin
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'nc:read', 'nc:write'}
# Failures
WARNING JWT issuer validation failed: Invalid issuer
WARNING Missing required scopes: nc:write
```
### Known Limitations
1. **No Fine-Grained Scopes** - Only coarse `nc:read` and `nc:write` (not per-app scopes)
2. **No Refresh Token Support** - Tokens must be reacquired when expired
### Future Enhancements
**Potential Improvements:**
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
- Resource-level filtering (apply to MCP resources, not just tools)
- Automatic scope discovery from decorated tools
- Admin UI for scope management
---
## References
### Standards
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
### Related Documentation
- [OAuth Setup Guide](oauth-setup.md) - Complete OAuth configuration guide
- [OAuth Architecture](oauth-architecture.md) - Detailed architecture documentation
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common OAuth issues and solutions
- [Authentication Guide](authentication.md) - BasicAuth vs OAuth comparison
### External Resources
- [Nextcloud OIDC App](https://github.com/H2CK/oidc) - OIDC identity provider for Nextcloud
- [PyJWT Documentation](https://pyjwt.readthedocs.io/) - JWT library used for verification
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - MCP server framework
---
**Implementation Date:** 2025-10-21 to 2025-10-23
**Version:** 1.0.0
**Status:** ✅ Production Ready
+6 -9
View File
@@ -217,11 +217,12 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
**How it works**:
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
2. Calls `/apps/oidc/register` to register new client
2. Calls `/apps/oidc/register` to register a client on first startup
3. Saves credentials to `.nextcloud_oauth_client.json`
4. Re-registers if credentials expire
4. Reuses these credentials on subsequent startups
5. Re-registers only if credentials are missing or expired
**Best for**: Development, testing, short-lived deployments
**Best for**: Development, testing, quick deployments
### Pre-configured Client
@@ -295,8 +296,7 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
The integration test suite includes comprehensive OAuth testing:
- **Automated tests** (Playwright): [`tests/integration/test_oauth_playwright.py`](../tests/integration/test_oauth_playwright.py)
- **Interactive tests**: [`tests/integration/test_oauth_interactive.py`](../tests/integration/test_oauth_interactive.py)
- **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:
@@ -305,10 +305,7 @@ Run OAuth tests:
docker-compose up --build -d mcp-oauth
# Run automated tests
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
# Run interactive tests (manual login)
uv run pytest tests/integration/test_oauth_interactive.py -v
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
```
## See Also
+7 -7
View File
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
### Mode A: Automatic Registration (Dynamic Client Registration)
**Best for**: Development, testing, short-lived deployments
**Best for**: Development, testing, quick deployments
**How it works**:
- MCP server automatically registers OAuth client at startup
- 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
- No manual client management
- Automatic credential management
**Cons**:
- Clients expire (default: 1 hour, configurable)
- Must re-register on restart if expired
- Not ideal for long-running production
- Must have dynamic client registration enabled on Nextcloud
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
@@ -192,8 +192,8 @@ You have two options for managing OAuth clients:
**Best for**: Production, long-running deployments, stable environments
**How it works**:
- You manually register OAuth client via Nextcloud CLI
- Provide client credentials to MCP server
- You manually register an OAuth client via Nextcloud CLI
- Provide client credentials to MCP server via environment variables
- Credentials don't expire
**Pros**:
+43 -27
View File
@@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
---
### 2. PKCE Support Advertisement in Discovery
### 2. PKCE Support (RFC 7636)
**Status**: 🟢 **PR Submitted** (Pending Review)
**Status**: **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
**Why It Matters**:
- MCP specification requires PKCE with S256 code challenge method
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
- Some MCP clients may reject providers without proper PKCE advertisement
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
**Current Behavior**:
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
- PKCE just isn't **advertised** in discovery metadata
**Authorization Endpoint** (`/authorize`):
- Accepts `code_challenge` and `code_challenge_method` parameters
- Validates code_challenge format (43-128 characters, unreserved chars only)
- Supports both `S256` (SHA-256) and `plain` challenge methods
- Stores challenge and method in database for later verification
**Recommended Fix**: Update `oidc` app to include:
**Token Endpoint** (`/token`):
- Accepts `code_verifier` parameter
- Verifies code_verifier against stored code_challenge using proper algorithm
- Uses constant-time comparison to prevent timing attacks
- Enforces code_verifier requirement when PKCE was used in authorization
**Discovery Document**:
```json
{
"code_challenge_methods_supported": ["S256"]
"code_challenge_methods_supported": ["S256", "plain"]
}
```
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
**Database**:
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
- Migration included for existing installations
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
- **Size**: +5 lines added, 0 deleted
- **Status**: Open, awaiting review
**Why It Mattered**:
- MCP specification requires PKCE with S256 code challenge method
- RFC 7636 PKCE provides security for public clients (no client secret)
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
- Prevents authorization code interception attacks
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
- **Changes**: Complete PKCE implementation (+194 lines)
- Authorization flow with code_challenge validation
- Token exchange with code_verifier verification
- Database schema updates
- Discovery document updates
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
---
@@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
| PR/Issue | Component | Status | Priority | Notes |
|----------|-----------|--------|----------|-------|
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
## What Works Without Patches
The following functionality works **out of the box** without any patches:
**OAuth Flow**:
- OIDC discovery
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
- Dynamic client registration
- Authorization code flow with PKCE
- Token exchange
- Authorization code flow with PKCE (S256 and plain methods)
- Token exchange with code_verifier verification
- Userinfo endpoint
**MCP Server as Resource Server**:
@@ -116,9 +132,9 @@ The following functionality requires upstream patches:
- Tables API
- Custom app APIs
🟡 **Standards Compliance** (PKCE advertisement):
- Full RFC 8414 compliance
- MCP client compatibility guarantee
**Standards Compliance**: Now complete with `oidc` app v1.10.0+
- Full RFC 8414 compliance (PKCE advertisement)
- MCP client compatibility guarantee
## Installation Instructions
@@ -171,7 +187,7 @@ The integration test suite validates OAuth functionality:
docker-compose up --build -d mcp-oauth
# Run comprehensive OAuth tests
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
# Tests verify:
# - OAuth flow completion
@@ -221,6 +237,6 @@ Want to help get these patches merged?
---
**Last Updated**: 2025-10-14
**Last Updated**: 2025-10-20
**Next Review**: When PR #584 or issue #1221 has activity
**Next Review**: When issue #1221 (Bearer token support) has activity
+4 -4
View File
@@ -151,11 +151,11 @@ curl https://your.nextcloud.instance.com/.well-known/openid-configuration
This quick start uses **automatic client registration** which is perfect for:
- Development
- Testing
- Short-lived deployments
- Quick deployments
For **production deployments**, you should:
1. Pre-register OAuth clients manually
2. Use dedicated client credentials
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
---
@@ -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://localhost: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://localhost: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://localhost: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://localhost:8000/mcp") as (read1, write1, _):
async with ClientSession(read1, write1) as session1:
await session1.initialize()
async with streamablehttp_client("http://localhost: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://localhost: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.
+412
View File
@@ -0,0 +1,412 @@
# Testing OIDC Consent Feature
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
## Setup
### Volume Mount Configuration
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
```yaml
# docker-compose.yml
volumes:
- ../Software/oidc:/opt/apps/oidc:ro
```
**Why mount outside `/var/www/html/`?**
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
- Mounting to `/opt/apps/oidc` avoids rsync entirely
- Nextcloud supports multiple app directories via the `apps_paths` configuration
**How multiple app paths work:**
- Nextcloud can load apps from multiple directories
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
- Apps in default paths (index 0 and 1) are still available
- All directories are scanned for apps, but `/opt/apps` is read-only
This setup allows you to:
- Test changes without rebuilding containers
- Avoid needing npm/node in the container (JS already built on host)
- Iterate quickly on development
- Install other Nextcloud apps normally (custom_apps remains writable)
### How It Works
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
## Starting the Stack
```bash
cd ~/Projects/nextcloud-mcp-server
# Start fresh (recommended for first test)
docker compose down -v
docker compose up -d
# Wait for initialization (check logs)
docker compose logs -f app
```
The post-installation hooks will:
1. Configure custom_apps path (already done)
2. Enable OIDC app from mounted directory
3. Run database migrations (including consent table creation)
4. Configure OIDC settings
## Verifying Installation
### Before Container Restart
Before running `docker compose up -d`, the consent feature will NOT be active:
- ❌ No `oc_oidc_user_consents` table in database
- ❌ Migration 0015 not applied yet
- ❌ ConsentController class not loaded
- ❌ Consent routes not registered
You can verify this with:
```bash
# Check migrations applied (should stop at 0014)
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
# Check for consent table (should return empty)
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
```
### After Container Restart
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
-`oc_oidc_user_consents` table exists
- ✅ Migration 0015 (Version0015Date20251123100100) applied
- ✅ ConsentController routes registered
- ✅ Consent screen appears during OAuth flows
### Check App Status
```bash
docker compose exec app php occ app:list | grep -A 2 oidc
```
Expected output:
```
- oidc: 1.10.0 (enabled)
```
### Verify App Paths Configuration
Verify that `/opt/apps` is registered as an additional app directory:
```bash
# Check configured app paths
docker compose exec app php occ config:system:get apps_paths
# Verify the mount is accessible
docker compose exec app ls -la /opt/apps/oidc/
# Verify custom_apps is writable (for normal app installation)
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
docker compose exec app rm -f /var/www/html/custom_apps/.test
```
Expected: Output should show multiple app paths including index 2 (/opt/apps).
### Verify Consent Files
```bash
# Check controller exists in mounted location
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
# Check Vue component exists
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
# Check built JS exists
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
```
### Verify Database Migration
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
```bash
# Check if consent table exists
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
# Check table structure
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
# Verify migration 0015 was applied
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
```
Expected table structure:
- id: int(10) unsigned, auto_increment, primary key
- user_id: varchar(256), not null
- client_id: int(10) unsigned, not null
- scopes_granted: varchar(512), not null
- created_at: int(10) unsigned, not null
- updated_at: int(10) unsigned, not null
- expires_at: int(10) unsigned, nullable
### Verify Routes
```bash
docker compose exec app php occ router:list | grep consent
```
Expected output:
```
oidc.Consent.show GET apps/oidc/consent
oidc.Consent.grant POST apps/oidc/consent/grant
oidc.Consent.deny POST apps/oidc/consent/deny
```
## Testing the Consent Flow
### 1. Create an OAuth Client
The JWT client is automatically created by the post-installation hooks:
```bash
# Check if JWT client exists
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
```
### 2. Initiate Authorization Flow
You can test using the MCP OAuth container or manually:
**Option A: Using MCP OAuth container**
```bash
# The mcp-oauth-jwt container will trigger the OAuth flow
docker compose logs -f mcp-oauth-jwt
```
**Option B: Manual browser test**
1. Get client_id from the JWT client JSON
2. Visit in browser:
```
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8002/oauth/callback&scope=openid+profile+email+nc:read+nc:write&state=test123
```
### 3. Expected Behavior
**First Authorization:**
1. User logs in (if not already authenticated)
2. **Consent screen appears** with:
- Application name: "Nextcloud MCP Server JWT"
- List of requested scopes with descriptions:
- ✓ Basic authentication (openid) - required, cannot deselect
- ✓ Profile information (profile)
- ✓ Email address (email)
- ✓ nc:read (custom scope, shown as-is)
- ✓ nc:write (custom scope, shown as-is)
- "Allow" and "Deny" buttons
3. User selects scopes and clicks "Allow"
4. Authorization proceeds with selected scopes
5. Consent is stored in database
**Subsequent Authorizations:**
- Same scopes → No consent screen (uses stored consent)
- Different scopes → Consent screen appears again
- If user clicks "Deny" → Returns `error=access_denied` to client
### 4. Verify Consent Stored
After granting consent:
```bash
# View all stored consents with formatted timestamps
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "
SELECT
user_id,
client_id,
scopes_granted,
FROM_UNIXTIME(created_at) as created,
FROM_UNIXTIME(updated_at) as updated,
FROM_UNIXTIME(expires_at) as expires
FROM oc_oidc_user_consents;
" nextcloud
# Or for a compact view:
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT * FROM oc_oidc_user_consents;" nextcloud
```
## Troubleshooting
### Consent Screen Not Appearing
**Check browser console** (F12 → Console tab):
```
# Look for JS errors like:
Failed to load resource: js/oidc-consent.js
```
**Check Nextcloud logs:**
```bash
docker compose exec app tail -f /var/www/html/data/nextcloud.log | grep -i consent
```
**Verify JS file loaded:**
```bash
# Check file exists and has correct size (~73KB)
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
```
**Clear Nextcloud caches:**
```bash
docker compose exec app php occ maintenance:repair
docker compose restart app
```
### Migration Didn't Run
**Check which migrations have been applied:**
```bash
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' ORDER BY version;" nextcloud
```
Expected to see `Version0015Date20251123100100` in the list.
**Manually trigger migrations:**
```bash
# Disable and re-enable app (triggers all pending migrations)
docker compose exec app php occ app:disable oidc
docker compose exec app php occ app:enable oidc
# Verify migration 0015 was applied
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';" nextcloud
```
### Routes Not Registered
If `router:list` doesn't show consent routes:
```bash
# The autoloader might not have picked up new classes
# Restart the container
docker compose restart app
# Wait for it to be ready
sleep 10
# Try again
docker compose exec app php occ router:list | grep consent
```
If still not working, check if ConsentController is accessible:
```bash
docker compose exec app php -r "
require_once '/var/www/html/lib/base.php';
\$class = 'OCA\\OIDCIdentityProvider\\Controller\\ConsentController';
if (class_exists(\$class)) {
echo \"Class exists\n\";
} else {
echo \"Class not found\n\";
}
"
```
## Making Changes
### Frontend Changes (Vue.js)
1. Edit source file on host:
```bash
cd ~/Software/oidc
# Edit src/Consent.vue
```
2. Rebuild JS:
```bash
npm run build
```
3. Refresh browser (container sees changes immediately via volume mount at /opt/apps/oidc)
### Backend Changes (PHP)
1. Edit files on host:
```bash
cd ~/Software/oidc
# Edit lib/Controller/ConsentController.php or other PHP files
```
2. Changes are immediately visible (PHP is interpreted, no build step)
3. For new classes or major changes, restart container:
```bash
docker compose restart app
```
### Database Schema Changes
If you modify the migration:
```bash
# Changes won't be picked up if migration already ran
# Need to recreate the database:
docker compose down -v # Removes volumes
docker compose up -d # Fresh start with clean DB
```
## Cleanup
### Reset Everything
```bash
cd ~/Projects/nextcloud-mcp-server
docker compose down -v
```
This removes:
- All containers
- Database volume (all data)
- OAuth client credentials
### Keep Data, Restart App
```bash
docker compose restart app
```
This preserves:
- Database (consents, clients, users)
- OAuth client credentials
## Development Workflow Summary
1. **Make changes** in `~/Software/oidc`
2. **Build JS** if you changed Vue files: `npm run build`
3. **Test immediately** - refresh browser or restart container
4. **No need** to rebuild Docker images or reinstall app
5. **Iterate quickly** with instant feedback
## Production Deployment
When ready to deploy:
1. **Create patch file** (already done):
```bash
cd ~/Software/oidc
git format-patch master --stdout > user-consent-feature.patch
```
2. **Test patch** in clean environment:
```bash
# In a production-like environment
cd /path/to/production/oidc
git apply user-consent-feature.patch
npm install
npm run build
php occ app:disable oidc
php occ app:enable oidc
```
3. **Verify migration** runs automatically on app enable
4. **Submit pull request** to upstream repository
+373 -105
View File
@@ -5,22 +5,33 @@ 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 starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
from nextcloud_mcp_server.auth import (
InsufficientScopeError,
NextcloudTokenVerifier,
get_access_token_scopes,
has_required_scopes,
is_jwt_token,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import setup_logging
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,
)
@@ -132,6 +143,95 @@ def is_oauth_mode() -> bool:
return True
async def load_oauth_client_credentials(
nextcloud_host: str, registration_endpoint: str | None
) -> tuple[str, str]:
"""
Load OAuth client credentials from environment, storage file, or dynamic registration.
This consolidates the client loading logic that was duplicated across multiple functions.
Args:
nextcloud_host: Nextcloud instance URL
registration_endpoint: Dynamic registration endpoint URL (or None if not available)
Returns:
Tuple of (client_id, client_secret)
Raises:
ValueError: If credentials cannot be obtained
"""
# Try environment variables first
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 from environment")
return (client_id, client_secret)
# Try loading from storage file
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
from pathlib import Path
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
client_info = load_client_from_file(Path(storage_path))
if client_info:
logger.info(
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
)
return (client_info.client_id, client_info.client_secret)
# Try dynamic registration if available
if 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"]
# Get scopes from environment or use defaults
scopes = os.getenv(
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
)
logger.info(f"Requesting OAuth scopes: {scopes}")
# Get token type from environment (Bearer or jwt)
# Note: Must be lowercase "jwt" to match OIDC app's check
token_type = os.getenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "Bearer").lower()
# Special case: "bearer" should remain capitalized for compatibility
if token_type != "jwt":
token_type = "Bearer"
logger.info(f"Requesting token type: {token_type}")
# Load or register client
from nextcloud_mcp_server.auth.client_registration import (
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=f"Nextcloud MCP Server ({token_type})",
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
return (client_info.client_id, client_info.client_secret)
# No credentials available
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app"
)
@asynccontextmanager
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
@@ -174,8 +274,6 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
try:
# Fetch OIDC discovery
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
@@ -186,45 +284,24 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
# Extract endpoints
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
introspection_uri = discovery.get("introspection_endpoint")
logger.info(f"Userinfo endpoint: {userinfo_uri}")
if introspection_uri:
logger.info(f"Introspection endpoint: {introspection_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"
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
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
# Create token verifier with introspection support
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
introspection_uri=introspection_uri,
client_id=client_id,
client_secret=client_secret,
)
logger.info("OAuth initialization complete")
@@ -264,8 +341,6 @@ async def setup_oauth_config():
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Fetch OIDC discovery
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
@@ -279,59 +354,60 @@ async def setup_oauth_config():
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_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)
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
# Allow override of public issuer URL for both client configuration and JWT validation
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
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}")
logger.info(
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
)
# Use public issuer for both client configuration AND JWT validation
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]}...")
jwt_validation_issuer = public_issuer
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"
)
# Use discovered issuer for both
jwt_validation_issuer = issuer
# Create token verifier
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
# Create auth settings
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Note: We don't set required_scopes here anymore.
# Scopes are now advertised via PRM endpoint and enforced per-tool.
# This allows dynamic tool filtering based on user's actual token scopes.
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(mcp_server_url),
required_scopes=["openid", "profile"],
)
logger.info("OAuth configuration complete")
@@ -350,9 +426,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Asynchronously get the OAuth configuration
import asyncio
nextcloud_host, token_verifier, auth_settings = asyncio.run(
setup_oauth_config()
)
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_oauth,
@@ -375,8 +449,10 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
"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,
}
@@ -394,6 +470,55 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
)
# Override list_tools to filter based on user's token scopes (OAuth mode only)
if oauth_enabled:
original_list_tools = mcp._tool_manager.list_tools
def list_tools_filtered():
"""List tools filtered by user's token scopes (JWT and Bearer tokens)."""
# Get user's scopes from token using MCP SDK's contextvar
# This works for all request types including list_tools
user_scopes = get_access_token_scopes()
is_jwt = is_jwt_token()
logger.info(
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, "
f"User scopes: {user_scopes}"
)
# Get all tools
all_tools = original_list_tools()
# Filter tools based on user's token scopes (both JWT and opaque tokens)
# JWT tokens have scopes embedded in payload
# Opaque tokens get scopes via introspection endpoint
# Claude Code now properly respects PRM endpoint for scope discovery
if user_scopes:
allowed_tools = [
tool
for tool in all_tools
if has_required_scopes(tool.fn, user_scopes)
]
token_type = "JWT" if is_jwt else "Bearer"
logger.info(
f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
f"available for scopes: {user_scopes}"
)
else:
# BasicAuth mode or no token - show all tools
allowed_tools = all_tools
logger.info(
f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)"
)
# Return the Tool objects directly (they're already in the correct format)
return allowed_tools
# Replace the tool manager's list_tools method
mcp._tool_manager.list_tools = list_tools_filtered
logger.info(
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
)
if transport == "sse":
mcp_app = mcp.sse_app()
lifespan = None
@@ -406,7 +531,96 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
await stack.enter_async_context(mcp.session_manager.run())
yield
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
routes = []
if oauth_enabled:
def oauth_protected_resource_metadata(request):
"""RFC 9728 Protected Resource Metadata endpoint."""
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
resource_url = f"{mcp_server_url}/mcp"
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if not public_issuer_url:
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
return JSONResponse(
{
"resource": resource_url,
"scopes_supported": ["openid", "nc:read", "nc:write"],
"authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
)
# Register PRM endpoint at both path-based and root locations per RFC 9728
# Path-based discovery: /.well-known/oauth-protected-resource{path}
routes.append(
Route(
"/.well-known/oauth-protected-resource/mcp",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
# Root discovery (fallback): /.well-known/oauth-protected-resource
routes.append(
Route(
"/.well-known/oauth-protected-resource",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
logger.info(
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
routes.append(Mount("/", app=mcp_app))
app = Starlette(routes=routes, lifespan=lifespan)
# Add CORS middleware to allow browser-based clients like MCP Inspector
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# Add exception handler for scope challenges (OAuth mode only)
if oauth_enabled:
@app.exception_handler(InsufficientScopeError)
async def handle_insufficient_scope(request, exc: InsufficientScopeError):
"""Return 403 with WWW-Authenticate header for scope challenges."""
resource_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
scope_str = " ".join(exc.missing_scopes)
return JSONResponse(
status_code=403,
headers={
"WWW-Authenticate": (
f'Bearer error="insufficient_scope", '
f'scope="{scope_str}", '
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource/mcp"'
)
},
content={
"error": "insufficient_scope",
"scopes_required": exc.missing_scopes,
},
)
logger.info("WWW-Authenticate scope challenge handler enabled")
return app
@@ -418,10 +632,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
@click.option(
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
)
@click.option(
"--workers", "-w", type=int, default=None, help="Number of worker processes"
)
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload")
@click.option(
"--log-level",
"-l",
@@ -442,7 +652,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
"--enable-app",
"-e",
multiple=True,
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
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(
@@ -474,11 +686,44 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
show_default=True,
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
)
@click.option(
"--nextcloud-host",
envvar="NEXTCLOUD_HOST",
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
)
@click.option(
"--nextcloud-username",
envvar="NEXTCLOUD_USERNAME",
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
)
@click.option(
"--nextcloud-password",
envvar="NEXTCLOUD_PASSWORD",
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
)
@click.option(
"--oauth-scopes",
envvar="NEXTCLOUD_OIDC_SCOPES",
default="openid profile email nc:read nc:write",
show_default=True,
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@click.option(
"--oauth-token-type",
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
default="bearer",
show_default=True,
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
)
@click.option(
"--public-issuer-url",
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
)
def run(
host: str,
port: int,
workers: int,
reload: bool,
log_level: str,
transport: str,
enable_app: tuple[str, ...],
@@ -487,6 +732,12 @@ def run(
oauth_client_secret: str | None,
oauth_storage_path: str,
mcp_server_url: str,
nextcloud_host: str | None,
nextcloud_username: str | None,
nextcloud_password: str | None,
oauth_scopes: str,
oauth_token_type: str,
public_issuer_url: str | None,
):
"""
Run the Nextcloud MCP server.
@@ -498,24 +749,52 @@ def run(
\b
Examples:
# BasicAuth mode (legacy)
# BasicAuth mode with CLI options
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
--nextcloud-username=admin --nextcloud-password=secret
# BasicAuth mode with env vars (recommended for credentials)
$ export NEXTCLOUD_HOST=https://cloud.example.com
$ export NEXTCLOUD_USERNAME=admin
$ export NEXTCLOUD_PASSWORD=secret
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
# OAuth mode with auto-registration
$ nextcloud-mcp-server --oauth
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
# OAuth mode with pre-configured client
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-client-id=xxx --oauth-client-secret=yyy
# OAuth mode with custom scopes and JWT tokens
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-scopes="openid nc:read" --oauth-token-type=jwt
# OAuth with public issuer URL (for Docker/proxy setups)
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
--public-issuer-url=http://localhost:8080
"""
# Set OAuth env vars from CLI options if provided
# Set env vars from CLI options if provided
if nextcloud_host:
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
if nextcloud_username:
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
if nextcloud_password:
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
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 oauth_scopes:
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
if oauth_token_type:
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
if mcp_server_url:
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
if public_issuer_url:
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
# Force OAuth mode if explicitly requested
if oauth is True:
@@ -585,21 +864,10 @@ def run(
enabled_apps = list(enable_app) if enable_app else None
if reload or workers:
app = "nextcloud_mcp_server.app:get_app"
factory = True
else:
app = get_app(transport=transport, enabled_apps=enabled_apps)
factory = False
app = get_app(transport=transport, enabled_apps=enabled_apps)
uvicorn.run(
app=app,
factory=factory,
host=host,
port=port,
reload=reload,
workers=workers,
log_level=log_level,
app=app, host=host, port=port, log_level=log_level, log_config=LOGGING_CONFIG
)
+18
View File
@@ -3,6 +3,16 @@
from .bearer_auth import BearerAuth
from .client_registration import load_or_register_client, register_client
from .context_helper import get_client_from_context
from .scope_authorization import (
InsufficientScopeError,
ScopeAuthorizationError,
check_scopes,
get_access_token_scopes,
get_required_scopes,
has_required_scopes,
is_jwt_token,
require_scopes,
)
from .token_verifier import NextcloudTokenVerifier
__all__ = [
@@ -11,4 +21,12 @@ __all__ = [
"register_client",
"load_or_register_client",
"get_client_from_context",
"require_scopes",
"ScopeAuthorizationError",
"InsufficientScopeError",
"check_scopes",
"get_access_token_scopes",
"get_required_scopes",
"has_required_scopes",
"is_jwt_token",
]
@@ -1,5 +1,6 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
@@ -67,6 +68,7 @@ async def register_client(
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
@@ -77,6 +79,7 @@ async def register_client(
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
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
Returns:
ClientInfo with registration details
@@ -95,6 +98,7 @@ async def register_client(
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": scopes,
"token_type": token_type,
}
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
@@ -113,8 +117,11 @@ async def register_client(
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {client_info.get('client_secret_expires_at')} "
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
@@ -211,7 +218,8 @@ async def load_or_register_client(
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
force_register: bool = True,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
@@ -219,7 +227,7 @@ async def load_or_register_client(
This function:
1. Checks for existing client credentials in storage
2. Validates the credentials are not expired
3. Registers a new client if needed
3. Registers a new client if needed (no stored credentials or expired)
4. Saves the new client credentials
Args:
@@ -228,7 +236,8 @@ async def load_or_register_client(
storage_path: Path to store client credentials
client_name: Name of the client application
redirect_uris: List of redirect URIs
force_register: Force registration even if valid credentials exist
scopes: Space-separated list of scopes to request (default: "openid profile email")
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
Returns:
ClientInfo with valid credentials
@@ -239,11 +248,10 @@ async def load_or_register_client(
"""
storage_path = Path(storage_path)
# Try to load existing client unless forced to register
if not force_register:
client_info = load_client_from_file(storage_path)
if client_info:
return client_info
# 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...")
@@ -252,6 +260,8 @@ async def load_or_register_client(
registration_endpoint=registration_endpoint,
client_name=client_name,
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
)
# Save to storage
@@ -0,0 +1,278 @@
"""Scope-based authorization for MCP tools."""
import logging
from functools import wraps
from typing import Callable
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
logger = logging.getLogger(__name__)
class ScopeAuthorizationError(Exception):
"""Raised when a request lacks required scopes."""
pass
class InsufficientScopeError(ScopeAuthorizationError):
"""Raised when request lacks required scopes (enables step-up auth).
This exception triggers a 403 response with WWW-Authenticate header
containing the missing scopes, allowing clients to perform step-up
authorization to obtain additional permissions.
"""
def __init__(self, missing_scopes: list[str], message: str | None = None):
self.missing_scopes = missing_scopes
super().__init__(
message or f"Missing required scopes: {', '.join(missing_scopes)}"
)
def require_scopes(*required_scopes: str):
"""
Decorator to require specific OAuth scopes for MCP tool execution.
This decorator:
1. Stores scope requirements as function metadata (_required_scopes attribute)
2. Checks that the access token contains all required scopes before execution
3. Raises ScopeAuthorizationError if any required scope is missing
The stored metadata enables dynamic tool filtering - tools can be hidden from
users who lack the necessary scopes.
Args:
*required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write")
Returns:
Decorated function that checks scopes before execution
Example:
```python
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(ctx: Context, note_id: int):
# This tool requires the nc:read scope
...
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(ctx: Context, ...):
# This tool requires the nc:write scope
...
```
Raises:
ScopeAuthorizationError: If required scopes are not present in the access token
"""
def decorator(func: Callable):
# Store scope requirements as function metadata for dynamic filtering
func._required_scopes = list(required_scopes) # type: ignore
# Find which parameter receives the Context (FastMCP injects it by name)
context_param_name = find_context_parameter(func)
@wraps(func)
async def wrapper(*args, **kwargs):
# Extract context from kwargs (where FastMCP injected it)
ctx: Context | None = (
kwargs.get(context_param_name) if context_param_name else None
)
if ctx is None:
# No context parameter found - likely BasicAuth mode
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# Check if we're in OAuth mode (access token available)
access_token: AccessToken | None = getattr(
ctx.request_context, "access_token", None
)
if access_token is None:
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# Extract scopes from access token
token_scopes = set(access_token.scopes or [])
required_scopes_set = set(required_scopes)
# Check if all required scopes are present
missing_scopes = required_scopes_set - token_scopes
if missing_scopes:
error_msg = (
f"Access denied to {func.__name__}: "
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
)
logger.warning(error_msg)
raise InsufficientScopeError(list(missing_scopes), error_msg)
# All required scopes present - allow execution
logger.debug(
f"Scope authorization passed for {func.__name__}: {required_scopes}"
)
return await func(*args, **kwargs)
return wrapper
return decorator
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
"""
Extract scopes from the authenticated user's access token.
This function uses MCP SDK's contextvar to access the token, which works
across all request types including list_tools.
Args:
ctx: FastMCP context object (unused, kept for compatibility)
Returns:
Set of scope strings, empty set if no token or no scopes
"""
# Use MCP SDK's get_access_token() which uses contextvars
# This works for all request types, including list_tools
access_token: AccessToken | None = get_access_token()
if access_token is None:
logger.debug("No access token found in auth context (likely BasicAuth mode)")
return set()
scopes = set(access_token.scopes or [])
logger.info(f"✅ Extracted scopes from access token: {scopes}")
return scopes
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
"""
Check if the request context has all required scopes.
Utility function for manual scope checking without decorator.
Args:
ctx: FastMCP context object
*required_scopes: Variable number of required scope strings
Returns:
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
Example:
```python
async def my_tool(ctx: Context):
has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write")
if not has_scopes:
# Handle missing scopes
...
```
"""
token_scopes = get_access_token_scopes(ctx)
# If no access token, assume BasicAuth mode (all operations allowed)
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
return True, set()
required_scopes_set = set(required_scopes)
missing_scopes = required_scopes_set - token_scopes
return len(missing_scopes) == 0, missing_scopes
def get_required_scopes(func: Callable) -> list[str]:
"""
Extract required scopes from a function decorated with @require_scopes.
Args:
func: Function to check (may be decorated)
Returns:
List of required scope strings, empty list if no scopes required
Example:
```python
@require_scopes("nc:read", "nc:write")
async def my_tool():
pass
scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"]
```
"""
return getattr(func, "_required_scopes", [])
def is_jwt_token() -> bool:
"""
Check if the current access token is in JWT format.
JWT tokens have 3 parts separated by dots (header.payload.signature).
Opaque tokens are random strings without this structure.
Returns:
True if current token is JWT format, False if opaque or no token
"""
access_token: AccessToken | None = get_access_token()
if access_token is None:
logger.debug("No access token found - not JWT")
return False
# JWT tokens have exactly 2 dots (3 parts)
token_string = access_token.token
is_jwt = "." in token_string and token_string.count(".") == 2
logger.debug(f"Token format check: is_jwt={is_jwt}")
return is_jwt
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
"""
Check if a user has all scopes required by a function.
Used for dynamic tool filtering - determines if a tool should be visible
to a user based on their token scopes.
Args:
func: Function decorated with @require_scopes
user_scopes: Set of scopes the user possesses
Returns:
True if user has all required scopes (or no scopes required), False otherwise
Example:
```python
@require_scopes("nc:write")
async def create_note():
pass
user_scopes = {"nc:read", "nc:write"}
can_see = has_required_scopes(create_note, user_scopes) # True
limited_user_scopes = {"nc:read"}
can_see = has_required_scopes(create_note, limited_user_scopes) # False
```
"""
required = get_required_scopes(func)
# No scopes required → always allow
if not required:
return True
# Empty user_scopes but scopes required → deny
if not user_scopes:
return False
# Check if user has all required scopes
return set(required).issubset(user_scopes)
+291 -16
View File
@@ -5,6 +5,8 @@ import time
from typing import Any
import httpx
import jwt
from jwt import PyJWKClient
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger(__name__)
@@ -12,22 +14,33 @@ logger = logging.getLogger(__name__)
class NextcloudTokenVerifier(TokenVerifier):
"""
Validates access tokens using Nextcloud OIDC userinfo endpoint.
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
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)
This verifier supports both JWT and opaque tokens:
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
2. For opaque tokens: Falls back to userinfo endpoint validation
3. Caches successful responses to avoid repeated API calls/verifications
The userinfo endpoint validates the token and returns user claims if valid,
or returns HTTP 400/401 if the token is invalid or expired.
JWT validation provides:
- Faster validation (no HTTP call needed)
- Direct scope extraction from token payload
- Signature verification using JWKS
Userinfo fallback provides:
- Support for opaque tokens
- Backward compatibility
- Additional validation layer
"""
def __init__(
self,
nextcloud_host: str,
userinfo_uri: str,
jwks_uri: str | None = None,
issuer: str | None = None,
introspection_uri: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
cache_ttl: int = 3600,
):
"""
@@ -36,26 +49,52 @@ class NextcloudTokenVerifier(TokenVerifier):
Args:
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
userinfo_uri: Full URL to the userinfo endpoint
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
issuer: Expected issuer claim value (for JWT verification)
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
client_id: OAuth client ID (required for introspection)
client_secret: OAuth client secret (required for introspection)
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.userinfo_uri = userinfo_uri
self.jwks_uri = jwks_uri
self.issuer = issuer
self.introspection_uri = introspection_uri
self.client_id = client_id
self.client_secret = client_secret
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
# HTTP client for userinfo/introspection requests
self._client = httpx.AsyncClient(timeout=10.0)
# PyJWKClient for JWT verification (lazy initialization)
self._jwks_client: PyJWKClient | None = None
if jwks_uri:
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
# Introspection support
if introspection_uri and client_id and client_secret:
logger.info(f"Token introspection enabled: {introspection_uri}")
elif introspection_uri:
logger.warning(
"Introspection URI provided but missing client credentials - introspection disabled"
)
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token by calling the userinfo endpoint.
Verify a bearer token using JWT verification, introspection, or 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
2. Attempts JWT verification if JWKS is configured and token looks like JWT
3. Falls back to introspection for opaque tokens (if configured)
4. Falls back to userinfo endpoint as last resort
5. Returns AccessToken with username and scopes
Args:
token: The bearer token to verify
@@ -69,13 +108,232 @@ class NextcloudTokenVerifier(TokenVerifier):
logger.debug("Token found in cache")
return cached
# Validate via userinfo endpoint
# Try JWT verification first if enabled and token looks like JWT
is_jwt_format = self._is_jwt_format(token)
logger.debug(
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
)
if self._jwks_client and is_jwt_format:
logger.debug("Attempting JWT verification...")
jwt_result = self._verify_jwt(token)
if jwt_result:
logger.info("Token validated via JWT verification")
return jwt_result
else:
logger.warning("JWT verification failed, will try other methods")
# For opaque tokens, try introspection if available
if self.introspection_uri and self.client_id and self.client_secret:
logger.debug("Attempting token introspection...")
try:
introspection_result = await self._verify_via_introspection(token)
if introspection_result:
logger.info("Token validated via introspection")
return introspection_result
except Exception as e:
logger.warning(f"Introspection failed: {e}")
# Fall back to userinfo endpoint validation (last resort)
logger.debug("Attempting userinfo endpoint validation...")
try:
return await self._verify_via_userinfo(token)
except Exception as e:
logger.warning(f"Token verification failed: {e}")
return None
def _is_jwt_format(self, token: str) -> bool:
"""
Check if token looks like a JWT (has 3 parts separated by dots).
Args:
token: The token to check
Returns:
True if token appears to be JWT format
"""
return "." in token and token.count(".") == 2
def _verify_jwt(self, token: str) -> AccessToken | None:
"""
Verify JWT token with signature validation using JWKS.
Args:
token: The JWT token to verify
Returns:
AccessToken if valid, None if invalid
"""
try:
# Get signing key from JWKS
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
# Verify and decode JWT
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=self.issuer,
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": True if self.issuer else False,
"verify_aud": False, # Skip audience validation for Bearer tokens
},
)
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
logger.debug(f"Full JWT payload: {payload}")
# Extract username (sub claim)
username = payload.get("sub")
if not username:
logger.error("No 'sub' claim found in JWT payload")
return None
# Extract scopes from scope claim (space-separated string)
scope_string = payload.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
)
# Extract expiration
exp = payload.get("exp")
if not exp:
logger.warning("No 'exp' claim in JWT, using default TTL")
exp = int(time.time() + self.cache_ttl)
# Cache the result
userinfo = {
"sub": username,
"scope": scope_string,
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
}
self._token_cache[token] = (userinfo, exp)
return AccessToken(
token=token,
client_id=payload.get("client_id", ""),
scopes=scopes,
expires_at=exp,
resource=username, # Store username in resource field (RFC 8707)
)
except jwt.ExpiredSignatureError:
logger.info("JWT token has expired")
return None
except jwt.InvalidIssuerError as e:
logger.warning(f"JWT issuer validation failed: {e}")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"JWT validation failed: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during JWT verification: {e}")
return None
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
"""
Validate token by calling the introspection endpoint (RFC 7662).
This method validates opaque tokens and retrieves their scopes.
Args:
token: The bearer token to introspect
Returns:
AccessToken if active, None if inactive or invalid
"""
try:
# Introspection requires client authentication
response = await self._client.post(
self.introspection_uri,
data={"token": token},
auth=(self.client_id, self.client_secret),
)
if response.status_code == 200:
introspection_data = response.json()
# Check if token is active
if not introspection_data.get("active", False):
logger.info("Token introspection returned inactive=false")
return None
logger.debug(
f"Token introspected successfully for user: {introspection_data.get('sub')}"
)
# Extract username
username = introspection_data.get("sub") or introspection_data.get(
"username"
)
if not username:
logger.error("No username found in introspection response")
return None
# Extract scopes (space-separated string)
scope_string = introspection_data.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(f"Extracted scopes from introspection: {scopes}")
# Extract expiration
exp = introspection_data.get("exp")
if exp:
expiry = float(exp)
else:
logger.warning(
"No 'exp' in introspection response, using default TTL"
)
expiry = time.time() + self.cache_ttl
# Cache the result
cache_data = {
"sub": username,
"scope": scope_string,
**{
k: v
for k, v in introspection_data.items()
if k not in ["sub", "scope", "active"]
},
}
self._token_cache[token] = (cache_data, expiry)
return AccessToken(
token=token,
client_id=introspection_data.get("client_id", ""),
scopes=scopes,
expires_at=int(expiry),
resource=username,
)
elif response.status_code in (400, 401, 403):
logger.warning(
f"Token introspection failed: HTTP {response.status_code}. "
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
f"token issued to different OAuth client, (2) Expired client credentials, "
f"(3) Invalid token. Will fall back to userinfo endpoint. "
f"Response: {response.text[:200] if response.text else 'empty'}"
)
return None
else:
logger.warning(
f"Unexpected response from introspection: {response.status_code}. "
f"Response: {response.text[:200] if response.text else 'empty'}"
)
return None
except httpx.TimeoutException:
logger.error("Timeout while introspecting token")
return None
except httpx.RequestError as e:
logger.error(f"Network error while introspecting token: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during token introspection: {e}")
return None
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
"""
Validate token by calling the userinfo endpoint.
@@ -169,15 +427,31 @@ class NextcloudTokenVerifier(TokenVerifier):
"""
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.
First attempts to read actual scopes from the 'scope' field (RFC 8693).
If not present, infers scopes from the claims present in the response.
Args:
userinfo: The userinfo response dictionary
Returns:
List of inferred scopes
List of scopes (actual or inferred)
"""
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
scope_string = userinfo.get("scope")
if scope_string:
scopes = scope_string.split() if isinstance(scope_string, str) else []
if scopes:
logger.debug(
f"Using actual scopes from userinfo: {scopes} (scope field present)"
)
return scopes
# Fallback: Infer scopes from claims present in response
# This maintains backward compatibility with OIDC providers that don't
# include the scope field in userinfo responses
logger.debug(
"No scope field in userinfo response, inferring scopes from claims"
)
scopes = ["openid"] # Always present
if "email" in userinfo:
@@ -194,6 +468,7 @@ class NextcloudTokenVerifier(TokenVerifier):
if "groups" in userinfo:
scopes.append("groups")
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
return scopes
def clear_cache(self):
+15 -4
View File
@@ -14,9 +14,13 @@ from httpx import (
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__)
@@ -68,9 +72,15 @@ class NextcloudClient:
self.notes = NotesClient(self._client, username)
self.webdav = WebDAVClient(self._client, username)
self.tables = TablesClient(self._client, username)
self.calendar = CalendarClient(self._client, username)
self.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()
@@ -113,13 +123,14 @@ class NextcloudClient:
async def notes_search_notes(self, *, query: str):
"""Search notes using token-based matching with relevance ranking."""
all_notes = await self.notes.get_all_notes()
return self._notes_search.search_notes(all_notes, query)
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."""
"""Close the HTTP client and CalDAV client."""
await self._client.aclose()
await self.calendar.close()
File diff suppressed because it is too large Load Diff
+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()
+16 -5
View File
@@ -99,7 +99,7 @@ class DeckClient(BaseNextcloudClient):
permission_edit: bool,
permission_share: bool,
permission_manage: bool,
) -> List[DeckACL]:
) -> DeckACL:
json_data = {
"type": type,
"participant": participant,
@@ -107,10 +107,14 @@ class DeckClient(BaseNextcloudClient):
"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
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
json=json_data,
headers=headers,
)
return [DeckACL(**acl) for acl in response.json()]
return DeckACL(**response.json())
async def update_acl_rule(
self,
@@ -127,13 +131,20 @@ class DeckClient(BaseNextcloudClient):
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
"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}"
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
headers=headers,
)
async def clone_board(
+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}")
+6 -8
View File
@@ -1,7 +1,7 @@
"""Client for Nextcloud Notes app operations."""
import logging
from typing import Any, Dict, List, Optional
from typing import Any, AsyncIterator, Dict, Optional
from .base import BaseNextcloudClient
@@ -16,24 +16,22 @@ class NotesClient(BaseNextcloudClient):
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
return response.json()
async def get_all_notes(self) -> List[Dict[str, Any]]:
"""Get all notes."""
notes = []
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": 50, "chunkCursor": cursor},
params={"chunkSize": 10, "chunkCursor": cursor},
)
notes.extend(response.json())
for note in response.json():
yield note
if "X-Notes-Chunk-Cursor" not in response.headers:
break
cursor = response.headers["X-Notes-Chunk-Cursor"]
return notes
async def get_note(self, note_id: int) -> Dict[str, Any]:
"""Get a specific note by ID."""
response = await self._make_request(
+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"]
+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
)
+376
View File
@@ -570,3 +570,379 @@ class WebDAVClient(BaseNextcloudClient):
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,
)
+18 -2
View File
@@ -2,17 +2,18 @@ import logging.config
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "http",
}
},
},
"formatters": {
"http": {
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
},
"loggers": {
"": {
@@ -29,6 +30,21 @@ LOGGING_CONFIG = {
"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,
},
},
}
@@ -1,13 +1,13 @@
"""Controller for notes search functionality."""
from typing import Any, Dict, List
from typing import Any, AsyncIterable, Dict, List
class NotesSearchController:
"""Handles notes search logic and scoring."""
def search_notes(
self, notes: List[Dict[str, Any]], query: str
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.
@@ -21,7 +21,7 @@ class NotesSearchController:
return []
# Process and score each note
for note in notes:
async for note in notes:
title_tokens, content_tokens = self._process_note_content(note)
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
+6
View File
@@ -65,11 +65,14 @@ from .tables import (
# WebDAV models
from .webdav import (
CopyResourceResponse,
CreateDirectoryResponse,
DeleteResourceResponse,
DirectoryListing,
FileInfo,
MoveResourceResponse,
ReadFileResponse,
SearchFilesResponse,
WriteFileResponse,
)
@@ -133,4 +136,7 @@ __all__ = [
"WriteFileResponse",
"CreateDirectoryResponse",
"DeleteResourceResponse",
"MoveResourceResponse",
"CopyResourceResponse",
"SearchFilesResponse",
]
+68
View File
@@ -180,3 +180,71 @@ class ManageCalendarResponse(BaseResponse):
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"
)
+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
+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
+13
View File
@@ -22,6 +22,8 @@ class FileInfo(BaseModel):
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]:
@@ -106,3 +108,14 @@ class CopyResourceResponse(StatusResponse):
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"
)
+4
View File
@@ -1,15 +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",
]
+229 -1
View File
@@ -4,8 +4,14 @@ from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.calendar import Calendar, ListCalendarsResponse
from nextcloud_mcp_server.models.calendar import (
Calendar,
ListCalendarsResponse,
ListTodosResponse,
Todo,
)
logger = logging.getLogger(__name__)
@@ -13,6 +19,7 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user"""
client = get_client(ctx)
@@ -22,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_create_event(
calendar_name: str,
title: str,
@@ -97,6 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_list_events(
calendar_name: str,
ctx: Context,
@@ -198,6 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
return events
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_get_event(
calendar_name: str,
event_uid: str,
@@ -209,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
return event_data
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_update_event(
calendar_name: str,
event_uid: str,
@@ -281,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_delete_event(
calendar_name: str,
event_uid: str,
@@ -291,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_create_meeting(
title: str,
date: str,
@@ -356,6 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_get_upcoming_events(
ctx: Context,
calendar_name: str = "", # Empty = all calendars
@@ -405,6 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
return all_events[:limit]
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_find_availability(
duration_minutes: int,
ctx: Context,
@@ -484,6 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_bulk_operations(
operation: str, # "update", "delete", "move"
ctx: Context,
@@ -732,6 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_manage_calendar(
action: str, # "create", "delete", "update", "list"
ctx: Context,
@@ -796,3 +813,214 @@ def configure_calendar_tools(mcp: FastMCP):
else:
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
# ============= Todo/Task Tools =============
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_list_todos(
calendar_name: str,
ctx: Context,
status: Optional[str] = None,
min_priority: Optional[int] = None,
categories: Optional[str] = None,
summary_contains: Optional[str] = None,
) -> ListTodosResponse:
"""List todos/tasks in a calendar with optional filtering.
Args:
calendar_name: Name of the calendar to list todos from
ctx: MCP context
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
min_priority: Filter by minimum priority (1=highest, 9=lowest)
categories: Filter by categories (comma-separated, e.g., "work,urgent")
summary_contains: Filter todos where summary contains this text
Returns:
List of todos matching the filters
"""
client = get_client(ctx)
# Build filters dictionary
filters = {}
if status is not None:
filters["status"] = status
if min_priority is not None:
filters["min_priority"] = min_priority
if categories is not None:
filters["categories"] = [cat.strip() for cat in categories.split(",")]
if summary_contains is not None:
filters["summary_contains"] = summary_contains
todos_data = await client.calendar.list_todos(
calendar_name, filters if filters else None
)
todos = [Todo(**todo_data) for todo_data in todos_data]
return ListTodosResponse(
todos=todos, calendar_name=calendar_name, total_count=len(todos)
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_create_todo(
calendar_name: str,
summary: str,
ctx: Context,
description: str = "",
status: str = "NEEDS-ACTION",
priority: int = 0,
due: str = "",
dtstart: str = "",
categories: str = "",
):
"""Create a new todo/task in a calendar.
Args:
calendar_name: Name of the calendar to create the todo in
summary: Todo title/summary
ctx: MCP context
description: Detailed description of the todo
status: Todo status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
priority: Priority (0=undefined, 1=highest, 9=lowest)
due: Due date/time (ISO format, e.g., "2025-01-15T14:00:00")
dtstart: Start date/time (ISO format)
categories: Comma-separated categories (e.g., "work,urgent")
Returns:
Dict with todo creation result
"""
client = get_client(ctx)
todo_data = {
"summary": summary,
"description": description,
"status": status,
"priority": priority,
"due": due,
"dtstart": dtstart,
"categories": categories,
}
return await client.calendar.create_todo(calendar_name, todo_data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_update_todo(
calendar_name: str,
todo_uid: str,
ctx: Context,
summary: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[int] = None,
percent_complete: Optional[int] = None,
due: Optional[str] = None,
dtstart: Optional[str] = None,
completed: Optional[str] = None,
categories: Optional[str] = None,
):
"""Update an existing todo/task.
Args:
calendar_name: Name of the calendar containing the todo
todo_uid: UID of the todo to update
ctx: MCP context
summary: New summary/title
description: New description
status: New status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
priority: New priority (0-9)
percent_complete: New completion percentage (0-100)
due: New due date/time (ISO format)
dtstart: New start date/time (ISO format)
completed: Completion timestamp (ISO format)
categories: New categories (comma-separated)
Returns:
Dict with todo update result
"""
client = get_client(ctx)
# Build update data with only non-None values
todo_data = {}
if summary is not None:
todo_data["summary"] = summary
if description is not None:
todo_data["description"] = description
if status is not None:
todo_data["status"] = status
if priority is not None:
todo_data["priority"] = priority
if percent_complete is not None:
todo_data["percent_complete"] = percent_complete
if due is not None:
todo_data["due"] = due
if dtstart is not None:
todo_data["dtstart"] = dtstart
if completed is not None:
todo_data["completed"] = completed
if categories is not None:
todo_data["categories"] = categories
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_delete_todo(
calendar_name: str,
todo_uid: str,
ctx: Context,
):
"""Delete a todo/task from a calendar.
Args:
calendar_name: Name of the calendar containing the todo
todo_uid: UID of the todo to delete
ctx: MCP context
Returns:
Dict with deletion status
"""
client = get_client(ctx)
return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_search_todos(
ctx: Context,
status: Optional[str] = None,
min_priority: Optional[int] = None,
categories: Optional[str] = None,
summary_contains: Optional[str] = None,
):
"""Search todos across all calendars with optional filtering.
Args:
ctx: MCP context
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
min_priority: Filter by minimum priority (1=highest, 9=lowest)
categories: Filter by categories (comma-separated, e.g., "work,urgent")
summary_contains: Filter todos where summary contains this text
Returns:
List of todos matching the filters from all calendars
"""
client = get_client(ctx)
# Build filters dictionary
filters = {}
if status is not None:
filters["status"] = status
if min_priority is not None:
filters["min_priority"] = min_priority
if categories is not None:
filters["categories"] = [cat.strip() for cat in categories.split(",")]
if summary_contains is not None:
filters["summary_contains"] = summary_contains
todos_data = await client.calendar.search_todos_across_calendars(
filters if filters else None
)
todos = [Todo(**todo_data) for todo_data in todos_data]
return ListTodosResponse(todos=todos, total_count=len(todos))
+8
View File
@@ -2,6 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
logger = logging.getLogger(__name__)
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:write")
async def nc_contacts_create_addressbook(
ctx: Context, *, name: str, display_name: str
):
@@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
async def nc_contacts_create_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
):
@@ -59,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
+608
View File
@@ -0,0 +1,608 @@
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.auth import require_scopes
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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})",
)
)
+26
View File
@@ -3,6 +3,7 @@ from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.deck import (
CardOperationResponse,
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
# Read Tools (converted from resources)
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards"""
client = get_client(ctx)
@@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
return boards
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
client = get_client(ctx)
@@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
return board
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board"""
client = get_client(ctx)
@@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
return stacks
@mcp.tool()
@require_scopes("nc:read")
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)
@@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
return stack
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
@@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
return []
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard:
@@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
return card
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board"""
client = get_client(ctx)
@@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
return board.labels
@mcp.tool()
@require_scopes("nc:read")
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)
@@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
# Create/Update/Delete Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_board(
ctx: Context, title: str, color: str
) -> CreateBoardResponse:
@@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
# Stack Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_stack(
ctx: Context, board_id: int, title: str, order: int
) -> CreateStackResponse:
@@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool()
@require_scopes("nc:write")
async def deck_update_stack(
ctx: Context,
board_id: int,
@@ -236,6 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_delete_stack(
ctx: Context, board_id: int, stack_id: int
) -> StackOperationResponse:
@@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_card(
ctx: Context,
board_id: int,
@@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_update_card(
ctx: Context,
board_id: int,
@@ -341,6 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_delete_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -362,6 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_archive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -383,6 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_unarchive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_reorder_card(
ctx: Context,
board_id: int,
@@ -435,6 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
# Label Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_label(
ctx: Context, board_id: int, title: str, color: str
) -> CreateLabelResponse:
@@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool()
@require_scopes("nc:write")
async def deck_update_label(
ctx: Context,
board_id: int,
@@ -475,6 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_delete_label(
ctx: Context, board_id: int, label_id: int
) -> LabelOperationResponse:
@@ -495,6 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card-Label Assignment Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_assign_label_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -517,6 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_remove_label_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -540,6 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card-User Assignment Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_assign_user_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
@@ -562,6 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
+60 -5
View File
@@ -1,10 +1,11 @@
import logging
from httpx import HTTPStatusError
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.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.notes import (
AppendContentResponse,
@@ -61,6 +62,13 @@ def configure_notes_tools(mcp: FastMCP):
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"))
@@ -77,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(
title: str, content: str, category: str, ctx: Context
) -> CreateNoteResponse:
"""Create a new note"""
"""Create a new note (requires nc:write scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.create_note(
@@ -92,6 +101,10 @@ def configure_notes_tools(mcp: FastMCP):
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(
@@ -118,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_update_note(
note_id: int,
etag: str,
@@ -126,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
category: str | None,
ctx: Context,
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category.
"""Update an existing note's title, content, or category (requires nc:write scope).
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.
@@ -146,6 +160,12 @@ def configure_notes_tools(mcp: FastMCP):
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"))
@@ -176,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
) -> AppendContentResponse:
@@ -192,6 +213,13 @@ def configure_notes_tools(mcp: FastMCP):
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"))
@@ -218,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content, returning only id, title, and category."""
"""Search notes by title or content, returning only id, title, and category (requires nc:read scope)."""
client = get_client(ctx)
try:
search_results_raw = await client.notes_search_notes(query=query)
@@ -238,6 +267,10 @@ def configure_notes_tools(mcp: FastMCP):
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(
@@ -259,12 +292,19 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID"""
"""Get a specific note by its ID (requires nc:read scope)"""
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"))
@@ -281,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
@@ -295,6 +336,13 @@ def configure_notes_tools(mcp: FastMCP):
"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(
@@ -319,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
logger.info("Deleting note %s", note_id)
@@ -330,6 +379,12 @@ def configure_notes_tools(mcp: FastMCP):
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"))
+140
View File
@@ -0,0 +1,140 @@
"""MCP tools for Nextcloud file/folder sharing operations."""
import json
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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()
@require_scopes("nc:write")
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)
+7
View File
@@ -2,6 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
logger = logging.getLogger(__name__)
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
def configure_tables_tools(mcp: FastMCP):
# Tables tools
@mcp.tool()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
async def nc_tables_read_table(
table_id: int,
ctx: Context,
@@ -33,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool()
@require_scopes("nc:write")
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
"""Insert a new row into a table.
@@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.create_row(table_id, data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
"""Update an existing row in a table.
@@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.update_row(row_id, data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
client = get_client(ctx)
+203 -63
View File
@@ -2,7 +2,9 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
logger = logging.getLogger(__name__)
@@ -10,6 +12,7 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
"""List files and directories in the specified NextCloud path.
@@ -18,18 +21,12 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
List of items with metadata including name, path, is_directory, size, content_type, last_modified
Examples:
# List root directory
await nc_webdav_list_directory("")
# List a specific folder
await nc_webdav_list_directory("Documents/Projects")
"""
client = get_client(ctx)
return await client.webdav.list_directory(path)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_read_file(path: str, ctx: Context):
"""Read the content of a file from NextCloud.
@@ -39,15 +36,6 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with path, content, content_type, size, and encoding (if binary)
Text files are decoded to UTF-8, binary files are base64 encoded
Examples:
# Read a text file
result = await nc_webdav_read_file("Documents/readme.txt")
logger.info(result['content']) # Decoded text content
# Read a binary file
result = await nc_webdav_read_file("Images/photo.jpg")
logger.info(result['encoding']) # 'base64'
"""
client = get_client(ctx)
content, content_type = await client.webdav.read_file(path)
@@ -77,6 +65,7 @@ def configure_webdav_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_write_file(
path: str, content: str, ctx: Context, content_type: str | None = None
):
@@ -89,13 +78,6 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating success
Examples:
# Write a text file
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
# Write binary data (base64 encoded)
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
"""
client = get_client(ctx)
@@ -111,6 +93,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_create_directory(path: str, ctx: Context):
"""Create a directory in NextCloud.
@@ -119,18 +102,12 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code (201 for created, 405 if already exists)
Examples:
# Create a single directory
await nc_webdav_create_directory("NewProject")
# Create nested directories (parent must exist)
await nc_webdav_create_directory("Projects/MyApp/docs")
"""
client = get_client(ctx)
return await client.webdav.create_directory(path)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_delete_resource(path: str, ctx: Context):
"""Delete a file or directory in NextCloud.
@@ -139,18 +116,12 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if not found)
Examples:
# Delete a file
await nc_webdav_delete_resource("old_document.txt")
# Delete a directory (will delete all contents)
await nc_webdav_delete_resource("temp_folder")
"""
client = get_client(ctx)
return await client.webdav.delete_resource(path)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -163,19 +134,6 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
Examples:
# Rename a file
await nc_webdav_move_resource("document.txt", "new_name.txt")
# Move a file to another directory
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
# Move a directory
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
# Move and overwrite if destination exists
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
"""
client = get_client(ctx)
return await client.webdav.move_resource(
@@ -183,6 +141,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -195,21 +154,202 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
Examples:
# Copy a file
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
# Copy a file to another directory
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
# Copy a directory
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
# Copy and overwrite if destination exists
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
"""
client = get_client(ctx)
return await client.webdav.copy_resource(
source_path, destination_path, overwrite
)
@mcp.tool()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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()
@require_scopes("nc:read")
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},
)
+53 -13
View File
@@ -1,29 +1,50 @@
[project]
name = "nextcloud-mcp-server"
version = "0.13.0"
description = ""
version = "0.19.1"
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.17,<1.18)",
"mcp[cli] (>=1.19,<1.20)",
"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",
"pyjwt[crypto]>=2.8.0", # JWT validation with RSA support
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Communications",
"Topic :: Internet :: WWW/HTTP",
]
[project.urls]
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
anyio_mode = "auto"
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
log_cli = 1
log_cli_level = "INFO"
log_level = "INFO"
log_cli_level = "ERROR"
log_level = "ERROR"
markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
@@ -31,6 +52,9 @@ markers = [
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"
@@ -40,9 +64,19 @@ 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 = [
@@ -50,11 +84,17 @@ dev = [
"ipython>=9.2.0",
"playwright>=1.49.1",
"pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
"pytest-playwright-asyncio>=0.7.1",
"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
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Script to automatically add @require_scopes decorators to MCP tools.
This script parses server module files and adds appropriate scope decorators
based on the operation type (read vs write).
Usage:
python scripts/add_scope_decorators.py [--dry-run] [--file FILE]
"""
import argparse
import ast
import re
from pathlib import Path
from typing import List, Tuple
# Operation patterns for classification
READ_PATTERNS = [
r".*_get_.*",
r".*_get$",
r".*_list_.*",
r".*_list$",
r".*_search_.*",
r".*_search$",
r".*_read_.*",
r".*_read$",
r".*_find_.*",
r".*_find$",
r".*_fetch_.*",
r".*_fetch$",
r".*_retrieve_.*",
r".*_retrieve$",
]
WRITE_PATTERNS = [
r".*_create_.*",
r".*_create$",
r".*_update_.*",
r".*_update$",
r".*_delete_.*",
r".*_delete$",
r".*_append_.*",
r".*_append$",
r".*_modify_.*",
r".*_modify$",
r".*_set_.*",
r".*_set$",
r".*_add_.*",
r".*_add$",
r".*_remove_.*",
r".*_remove$",
r".*_edit_.*",
r".*_edit$",
r".*_move_.*",
r".*_move$",
r".*_copy_.*",
r".*_copy$",
r".*_upload_.*",
r".*_upload$",
r".*_download_.*",
r".*_download$",
r".*_share_.*",
r".*_share$",
r".*_unshare_.*",
r".*_unshare$",
r".*_bulk_.*", # Bulk operations are typically writes
]
def classify_operation(func_name: str) -> str | None:
"""Classify a function as read or write operation.
Args:
func_name: Function name to classify
Returns:
"nc:read", "nc:write", or None if cannot classify
"""
# Check write patterns first (more specific)
for pattern in WRITE_PATTERNS:
if re.match(pattern, func_name):
return "nc:write"
# Check read patterns
for pattern in READ_PATTERNS:
if re.match(pattern, func_name):
return "nc:read"
return None
def has_scope_decorator(decorators: List[ast.expr]) -> bool:
"""Check if function already has @require_scopes decorator."""
for decorator in decorators:
if isinstance(decorator, ast.Call):
if (
isinstance(decorator.func, ast.Name)
and decorator.func.id == "require_scopes"
):
return True
elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes":
return True
return False
def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool:
"""Check if function has @mcp.tool() decorator."""
for decorator in decorators:
if isinstance(decorator, ast.Call):
if isinstance(decorator.func, ast.Attribute):
if decorator.func.attr == "tool":
return True
return False
def find_tools_needing_decorators(
file_path: Path, verbose: bool = False
) -> List[Tuple[str, int, str]]:
"""Find all tools that need scope decorators.
Returns:
List of (function_name, line_number, required_scope)
"""
with open(file_path) as f:
content = f.read()
try:
tree = ast.parse(content)
except SyntaxError as e:
print(f" ⚠️ Syntax error in {file_path}: {e}")
return []
tools_to_update = []
total_functions = 0
mcp_tools = 0
already_has_scope = 0
cannot_classify = 0
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
total_functions += 1
if verbose and node.decorator_list:
decorators_str = [
ast.unparse(d) if hasattr(ast, "unparse") else str(d)
for d in node.decorator_list
]
print(f" Function {node.name} has decorators: {decorators_str}")
# Check if it's an MCP tool
if not has_mcp_tool_decorator(node.decorator_list):
continue
mcp_tools += 1
# Check if it already has scope decorator
if has_scope_decorator(node.decorator_list):
already_has_scope += 1
continue
# Classify operation
scope = classify_operation(node.name)
if scope:
tools_to_update.append((node.name, node.lineno, scope))
else:
cannot_classify += 1
if verbose:
print(f" ⚠️ Cannot classify: {node.name}")
if verbose:
print(
f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}"
)
return tools_to_update
def add_decorator_to_file(
file_path: Path, dry_run: bool = False, verbose: bool = False
) -> int:
"""Add @require_scopes decorators to tools in a file.
Returns:
Number of decorators added
"""
tools = find_tools_needing_decorators(file_path, verbose=verbose)
if not tools:
return 0
print(f"\n📝 {file_path.relative_to(Path.cwd())}")
with open(file_path) as f:
lines = f.readlines()
# Check if require_scopes is already imported
has_import = False
import_line_idx = None
for i, line in enumerate(lines):
if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line:
has_import = True
break
elif "from nextcloud_mcp_server.auth import" in line:
import_line_idx = i
# Add import if needed
if not has_import:
if import_line_idx is not None:
# Add require_scopes to existing import
old_line = lines[import_line_idx]
if "(" in old_line:
# Multi-line import
print(
" ⚠️ Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes"
)
else:
# Single line import - add require_scopes
lines[import_line_idx] = (
old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n"
)
print(" ✓ Added require_scopes to import")
else:
# No auth import exists, add new import
# Find first import line
for i, line in enumerate(lines):
if line.startswith("from nextcloud_mcp_server"):
lines.insert(
i, "from nextcloud_mcp_server.auth import require_scopes\n"
)
print(
" ✓ Added import: from nextcloud_mcp_server.auth import require_scopes"
)
break
# Add decorators to tools (in reverse order to preserve line numbers)
for func_name, line_num, scope in reversed(tools):
# Find the @mcp.tool() decorator line
for i in range(line_num - 1, max(0, line_num - 10), -1):
if "@mcp.tool()" in lines[i]:
# Get indentation from @mcp.tool() line
indent = len(lines[i]) - len(lines[i].lstrip())
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
lines.insert(i + 1, decorator_line)
print(f'{func_name}:{line_num} → @require_scopes("{scope}")')
break
if not dry_run:
with open(file_path, "w") as f:
f.writelines(lines)
print(" 💾 Saved changes")
else:
print(" 🔍 DRY RUN - no changes written")
return len(tools)
def main():
parser = argparse.ArgumentParser(
description="Add @require_scopes decorators to MCP tools"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--file",
type=Path,
help="Process a single file instead of all server modules",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Show debug information",
)
args = parser.parse_args()
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
if args.file:
files = [args.file]
else:
files = sorted(server_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
print("🔍 Scanning for tools needing scope decorators...")
print(
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
)
total_added = 0
for file_path in files:
added = add_decorator_to_file(
file_path, dry_run=args.dry_run, verbose=args.verbose
)
total_added += added
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
print(f" Total decorators added: {total_added}")
if args.dry_run:
print("\n💡 Run without --dry-run to apply changes")
if __name__ == "__main__":
main()
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Simpler script to add @require_scopes decorators using regex.
This script uses regex patterns to find @mcp.tool() decorators and adds
the appropriate @require_scopes decorator based on function name patterns.
Usage:
python scripts/add_scope_decorators_simple.py [--dry-run]
"""
import argparse
import re
from pathlib import Path
# Operation patterns for classification
READ_KEYWORDS = [
"get",
"list",
"search",
"read",
"find",
"fetch",
"retrieve",
"upcoming",
]
WRITE_KEYWORDS = [
"create",
"update",
"delete",
"append",
"modify",
"set",
"add",
"remove",
"edit",
"move",
"copy",
"upload",
"download",
"share",
"unshare",
"bulk",
"manage",
"import",
"reindex",
"archive",
"unarchive",
"reorder",
"assign",
"unassign",
"insert",
"write",
]
def classify_function(func_name: str) -> str | None:
"""Classify a function name as read or write operation."""
func_lower = func_name.lower()
# Check write keywords first (more specific)
for keyword in WRITE_KEYWORDS:
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
return "nc:write"
# Check read keywords
for keyword in READ_KEYWORDS:
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
return "nc:read"
return None
def process_file(file_path: Path, dry_run: bool = False) -> int:
"""Process a single file to add @require_scopes decorators.
Returns:
Number of decorators added
"""
with open(file_path) as f:
lines = f.readlines()
# Check if require_scopes is already imported
has_import = False
import_line_idx = None
for i, line in enumerate(lines):
if "from nextcloud_mcp_server.auth import" in line:
if "require_scopes" in line:
has_import = True
else:
import_line_idx = i
modified = False
decorators_added = 0
# Find all @mcp.tool() decorators
i = 0
while i < len(lines):
line = lines[i]
# Look for @mcp.tool() decorator
if re.match(r"\s*@mcp\.tool\(\)", line):
# Check if next line already has @require_scopes
if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]:
i += 1
continue
# Find the function definition (should be on next line or after other decorators)
func_line_idx = i + 1
while func_line_idx < len(lines) and not lines[
func_line_idx
].strip().startswith("async def"):
func_line_idx += 1
if func_line_idx >= len(lines):
i += 1
continue
# Extract function name
func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx])
if not func_match:
i += 1
continue
func_name = func_match.group(1)
scope = classify_function(func_name)
if scope:
# Get indentation from @mcp.tool() line
indent = len(line) - len(line.lstrip())
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
# Insert after @mcp.tool()
lines.insert(i + 1, decorator_line)
decorators_added += 1
modified = True
print(f'{func_name} → @require_scopes("{scope}")')
else:
print(f" ⚠️ Cannot classify: {func_name}")
i += 1
# Add import if needed and decorators were added
if decorators_added > 0 and not has_import:
if import_line_idx is not None:
# Add to existing import
old_line = lines[import_line_idx]
if old_line.rstrip().endswith(")"):
lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n"
else:
lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n"
print(" ✓ Added require_scopes to existing import")
modified = True
else:
# No auth import exists, add new import after last 'from nextcloud_mcp_server' import
last_nc_import_idx = None
for i, line in enumerate(lines):
if line.startswith("from nextcloud_mcp_server"):
last_nc_import_idx = i
if last_nc_import_idx is not None:
lines.insert(
last_nc_import_idx + 1,
"from nextcloud_mcp_server.auth import require_scopes\n",
)
print(
" ✓ Added new import: from nextcloud_mcp_server.auth import require_scopes"
)
modified = True
else:
print(" ⚠️ Could not find place to add require_scopes import")
# Write changes
if modified and not dry_run:
with open(file_path, "w") as f:
f.writelines(lines)
print(f" 💾 Saved changes to {file_path.name}")
elif dry_run and decorators_added > 0:
print(f" 🔍 DRY RUN - would add {decorators_added} decorators")
return decorators_added
def main():
parser = argparse.ArgumentParser(
description="Add @require_scopes decorators to MCP tools"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--file",
type=Path,
help="Process a single file instead of all server modules",
)
args = parser.parse_args()
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
if args.file:
files = [args.file]
else:
files = sorted(server_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
print("🔍 Scanning for tools needing scope decorators...")
print(
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
)
total_added = 0
for file_path in files:
file_path = file_path.resolve() # Convert to absolute path
try:
display_path = file_path.relative_to(Path.cwd())
except ValueError:
display_path = file_path.name
print(f"\n📝 {display_path}")
added = process_file(file_path, dry_run=args.dry_run)
total_added += added
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
print(f" Total decorators added: {total_added}")
if args.dry_run and total_added > 0:
print("\n💡 Run without --dry-run to apply changes")
if __name__ == "__main__":
main()
View File
View File
+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__)
@@ -1,4 +1,9 @@
"""Integration tests for Calendar CalDAV operations."""
"""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
@@ -15,50 +20,13 @@ logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.fixture
def test_calendar_name():
"""Unique calendar name for testing."""
return f"test_calendar_{uuid.uuid4().hex[:8]}"
@pytest.fixture
async def temporary_calendar(nc_client: NextcloudClient, test_calendar_name: str):
"""Create a temporary calendar for testing and clean up afterward."""
calendar_name = test_calendar_name
try:
# Create a test calendar
logger.info(f"Creating temporary calendar: {calendar_name}")
result = await nc_client.calendar.create_calendar(
calendar_name=calendar_name,
display_name=f"Test Calendar {calendar_name}",
description="Temporary calendar for integration testing",
color="#FF5722",
)
if result["status_code"] not in [200, 201]:
pytest.skip(f"Failed to create temporary calendar: {result}")
logger.info(f"Created temporary calendar: {calendar_name}")
yield calendar_name
except Exception as e:
logger.error(f"Error setting up temporary calendar: {e}")
pytest.skip(f"Calendar setup failed: {e}")
finally:
# Cleanup: Delete the temporary calendar
try:
logger.info(f"Cleaning up temporary calendar: {calendar_name}")
await nc_client.calendar.delete_calendar(calendar_name)
logger.info(f"Successfully deleted temporary calendar: {calendar_name}")
except Exception as e:
logger.error(f"Error deleting temporary calendar {calendar_name}: {e}")
@pytest.fixture
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
"""Create a temporary event for testing and clean up afterward."""
"""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
@@ -351,11 +319,11 @@ async def test_get_nonexistent_event(
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
with pytest.raises(HTTPStatusError) as exc_info:
# 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)
assert exc_info.value.response.status_code == 404
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}")
async def test_delete_nonexistent_event(
@@ -420,7 +388,11 @@ async def test_calendar_operations_error_handling(
# Test with non-existent calendar
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
with pytest.raises(HTTPStatusError):
await nc_client.calendar.get_calendar_events(fake_calendar)
# 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")
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
@pytest.mark.integration
async def test_calendar_event_custom_fields_preservation(nc_client):
"""Test that demonstrates loss of non-supported iCal fields during round-trip operations."""
"""Test that custom iCal fields are preserved during round-trip update operations."""
calendar_name = "personal"
# Create an event with standard fields
@@ -32,7 +32,12 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
event_uid = result["uid"]
try:
# Now manually inject a custom iCal property by creating a new version with raw iCal
# 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
@@ -57,22 +62,15 @@ LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
END:VEVENT
END:VCALENDAR"""
# Direct CalDAV PUT to inject the custom iCal
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
await nc_client.calendar._make_request(
"PUT",
event_path,
content=custom_ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
# 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}")
# Retrieve the event to confirm custom fields are present in raw iCal
response = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
raw_ical_before = response.text
# 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)
@@ -93,31 +91,24 @@ END:VCALENDAR"""
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
logger.info(f"Updated event {event_uid} through MCP client")
# Retrieve the event again to see if custom fields survived
response_after = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
raw_ical_after = response_after.text
# 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 TEST THAT SHOULD FAIL - custom fields should be preserved but won't be
try:
assert (
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
in raw_ical_after
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
)
logger.info(
"✓ Custom fields were preserved (unexpected - this should fail with current implementation)"
)
except AssertionError as e:
logger.error(f"✗ Custom fields were lost during round-trip update: {e}")
# Re-raise to show the test failure
raise
# 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
@@ -299,7 +290,7 @@ END:VCARD"""
@pytest.mark.integration
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
"""Demonstrates specific data loss scenarios in calendar events."""
"""Test that extended iCal properties are preserved during round-trip update operations."""
calendar_name = "personal"
event_data = {
@@ -313,6 +304,11 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
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
@@ -342,20 +338,13 @@ LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
END:VEVENT
END:VCALENDAR"""
# Inject the extended iCal
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
await nc_client.calendar._make_request(
"PUT",
event_path,
content=extended_ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
# Update the event's raw data and save
event.data = extended_ical
await event.save()
# Verify extended properties are present
response = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
original_ical = response.text
# Reload to verify extended properties are present
await event.load()
original_ical = event.data
# Confirm extended properties exist
extended_properties = [
@@ -392,11 +381,9 @@ END:VCALENDAR"""
update_data = {"location": "Conference Room B"} # Simple location change
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
# Check what survived the round-trip
response_after = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
updated_ical = response_after.text
# 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...")
@@ -423,13 +410,16 @@ END:VCALENDAR"""
lost.append(prop)
logger.info(f"Properties that SURVIVED: {survived}")
logger.error(f"Properties that were LOST: {lost}")
if lost:
logger.error(f"Properties that were LOST: {lost}")
# This test should fail - we expect data loss
# 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)
@@ -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
+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}")
+2 -2
View File
@@ -30,7 +30,7 @@ async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can list notes."""
notes = await nc_oauth_client.notes.get_all_notes()
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")
@@ -95,7 +95,7 @@ async def test_invalid_token_fails():
# Attempt to use a protected endpoint - should fail with 401
# Note: capabilities endpoint is public and doesn't require auth
with pytest.raises(HTTPStatusError) as exc_info:
await invalid_client.notes.get_all_notes()
_ = [note async for note in invalid_client.notes.get_all_notes()]
assert exc_info.value.response.status_code == 401
-41
View File
@@ -1,41 +0,0 @@
"""Interactive integration tests for OAuth authentication."""
import logging
import os
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.skipif(
"GITHUB_ACTIONS" in os.environ,
reason="Unable to access interactive browser in GitHub Actions",
)
async def test_oauth_client_with_interactive_flow(nc_oauth_client_interactive):
"""Test that OAuth client created via interactive flow can access Nextcloud APIs."""
# Test 1: Check capabilities
capabilities = await nc_oauth_client_interactive.capabilities()
assert capabilities is not None
logger.info("OAuth client (interactive) successfully fetched capabilities")
# Test 2: List notes
notes = await nc_oauth_client_interactive.notes.get_all_notes()
assert isinstance(notes, list)
logger.info(f"OAuth client (interactive) successfully listed {len(notes)} notes")
# Test 3: Create and delete a note
test_note = await nc_oauth_client_interactive.notes.create_note(
title="OAuth Interactive Test Note",
content="This note was created during OAuth interactive testing",
)
assert test_note is not None
assert test_note.get("id") is not None
note_id = test_note["id"]
logger.info(f"OAuth client (interactive) successfully created note {note_id}")
# Clean up
await nc_oauth_client_interactive.notes.delete_note(note_id=note_id)
logger.info(f"OAuth client (interactive) successfully deleted note {note_id}")
+3 -3
View File
@@ -19,14 +19,14 @@ async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
)
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
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_playwright.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 = await nc_oauth_client_playwright.notes.get_all_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
+268
View File
@@ -0,0 +1,268 @@
"""Integration tests for WebDAV search 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
@pytest.fixture
async def test_search_setup(nc_client: NextcloudClient):
"""Create test files and directories for search testing."""
test_dir = f"mcp_search_test_{uuid.uuid4().hex[:8]}"
# Create base directory
await nc_client.webdav.create_directory(test_dir)
# Create various test files
test_files = [
# Text files
(f"{test_dir}/document1.txt", b"Sample document content", "text/plain"),
(f"{test_dir}/document2.txt", b"Another document", "text/plain"),
(f"{test_dir}/report.txt", b"Report content", "text/plain"),
# Markdown files
(f"{test_dir}/readme.md", b"# README\nMarkdown content", "text/markdown"),
(f"{test_dir}/notes.md", b"# Notes\nSome notes here", "text/markdown"),
# PDF (simulated as binary)
(
f"{test_dir}/presentation.pdf",
b"%PDF-1.4 fake pdf content",
"application/pdf",
),
# Subdirectory with files
(f"{test_dir}/subdir/nested.txt", b"Nested file content", "text/plain"),
]
# Create subdirectory
await nc_client.webdav.create_directory(f"{test_dir}/subdir")
# Write all test files
for file_path, content, content_type in test_files:
await nc_client.webdav.write_file(file_path, content, content_type)
logger.info(f"Created test directory with {len(test_files)} files: {test_dir}")
yield test_dir
# Cleanup
try:
await nc_client.webdav.delete_resource(test_dir)
logger.info(f"Cleaned up test directory: {test_dir}")
except Exception as e:
logger.warning(f"Failed to cleanup test directory {test_dir}: {e}")
async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str):
"""Test finding files by exact name."""
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
assert len(results) >= 1, "Should find at least one readme.md file"
# Check that we found the right file
readme_files = [r for r in results if r.get("name") == "readme.md"]
assert len(readme_files) >= 1, "Should find readme.md"
logger.info(f"Found {len(results)} files matching 'readme.md'")
async def test_find_by_name_wildcard_extension(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test finding files by extension using wildcard."""
# Find all .txt files
results = await nc_client.webdav.find_by_name("%.txt", scope=test_search_setup)
assert len(results) >= 3, "Should find at least 3 .txt files"
# Verify all results are .txt files
for result in results:
name = result.get("name", "")
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
logger.info(f"Found {len(results)} .txt files")
async def test_find_by_name_wildcard_prefix(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test finding files by name prefix using wildcard."""
# Find all files starting with "document"
results = await nc_client.webdav.find_by_name("document%", scope=test_search_setup)
assert len(results) >= 2, "Should find at least 2 files starting with 'document'"
# Verify all results start with "document"
for result in results:
name = result.get("name", "")
assert name.startswith("document"), (
f"Expected name to start with 'document', got {name}"
)
logger.info(f"Found {len(results)} files starting with 'document'")
async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str):
"""Test finding files by MIME type (text files)."""
# Find all text files
results = await nc_client.webdav.find_by_type("text/%", scope=test_search_setup)
assert len(results) >= 5, "Should find at least 5 text files"
# Verify all results are text files
for result in results:
content_type = result.get("content_type", "")
assert content_type.startswith("text/"), (
f"Expected text/* type, got {content_type}"
)
logger.info(f"Found {len(results)} text files")
async def test_find_by_type_specific(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test finding files by specific MIME type."""
# Find PDF files
results = await nc_client.webdav.find_by_type(
"application/pdf", scope=test_search_setup
)
assert len(results) >= 1, "Should find at least 1 PDF file"
# Verify result is PDF
for result in results:
content_type = result.get("content_type", "")
assert content_type == "application/pdf", (
f"Expected application/pdf, got {content_type}"
)
logger.info(f"Found {len(results)} PDF files")
async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str):
"""Test search with result limit."""
# Search for .txt files with limit of 2
results = await nc_client.webdav.find_by_name(
"%.txt", scope=test_search_setup, limit=2
)
# Should return at most 2 results
assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}"
assert len(results) > 0, "Should return at least 1 result"
logger.info(f"Found {len(results)} files with limit=2")
async def test_search_files_combined_filters(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test search with multiple filters combined."""
# This test uses the search_files method directly to test combined conditions
# Search for .txt files that match a specific pattern
where_conditions = """
<d:and>
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>%.txt</d:literal>
</d:like>
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>document%</d:literal>
</d:like>
</d:and>
"""
results = await nc_client.webdav.search_files(
scope=test_search_setup, where_conditions=where_conditions
)
# Should find document1.txt and document2.txt
assert len(results) >= 2, "Should find at least 2 files matching both conditions"
# Verify results match both conditions
for result in results:
name = result.get("name", "")
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
assert name.startswith("document"), (
f"Expected name to start with 'document', got {name}"
)
logger.info(f"Found {len(results)} files matching combined filters")
async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str):
"""Test search in empty scope (user root)."""
# Search entire user root for a unique filename
unique_name = "readme.md"
results = await nc_client.webdav.find_by_name(unique_name, scope="")
# Should find at least the one we created
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}"
logger.info(f"Found {len(results)} files in root scope")
async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str):
"""Test search within a subdirectory."""
# Search in the subdir for the nested file
results = await nc_client.webdav.find_by_name(
"nested.txt", scope=f"{test_search_setup}/subdir"
)
assert len(results) >= 1, "Should find nested.txt in subdirectory"
# Verify the file path
nested_file = results[0]
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt"
logger.info(f"Found file in subdirectory: {nested_file.get('name')}")
async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str):
"""Test search that returns no results."""
# Search for a non-existent pattern
results = await nc_client.webdav.find_by_name(
"nonexistent_file_xyz123.txt", scope=test_search_setup
)
assert len(results) == 0, "Should return empty results for non-existent file"
logger.info("Search correctly returned no results for non-existent file")
async def test_search_properties_returned(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test that search returns expected properties."""
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
assert len(results) >= 1, "Should find at least one file"
result = results[0]
# Check for expected properties
assert "name" in result, "Should include name property"
assert "path" in result, "Should include path property"
assert "is_directory" in result, "Should include is_directory property"
assert result["is_directory"] is False, "readme.md should not be a directory"
# Optional properties that may be present
optional_props = ["size", "content_type", "last_modified", "etag"]
logger.info(f"Result properties: {list(result.keys())}")
# At least some optional properties should be present
present_optional = [prop for prop in optional_props if prop in result]
assert len(present_optional) > 0, f"Should have at least one of {optional_props}"
logger.info(f"Search returned properties: {list(result.keys())}")
+1620 -449
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type text/html;
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
try_files $uri $uri.html =404;
}
# Serve test_recipe.html at /black-pepper-tofu
location = /black-pepper-tofu {
root /usr/share/nginx/html;
try_files /test_recipe.html =404;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Black Pepper Tofu Recipe - Test Recipe</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Black Pepper Tofu",
"author": {
"@type": "Person",
"name": "Yotam Ottolenghi"
},
"datePublished": "2024-01-15",
"description": "A flavorful black pepper tofu dish with aromatic spices and crispy texture. Inspired by Yotam Ottolenghi's signature style.",
"prepTime": "PT15M",
"cookTime": "PT20M",
"totalTime": "PT35M",
"recipeYield": "4",
"recipeCategory": "Main Course",
"recipeCuisine": "Asian Fusion",
"keywords": "tofu, black pepper, vegetarian, vegan, ottolenghi",
"image": "https://example.com/black-pepper-tofu.jpg",
"recipeIngredient": [
"400g firm tofu, pressed and cubed",
"2 tablespoons black peppercorns, coarsely ground",
"3 tablespoons soy sauce",
"2 tablespoons rice vinegar",
"1 tablespoon maple syrup",
"2 tablespoons cornstarch",
"3 tablespoons vegetable oil",
"4 cloves garlic, minced",
"1 tablespoon fresh ginger, grated",
"2 spring onions, sliced",
"1 red bell pepper, sliced",
"Sesame seeds for garnish"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"text": "Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes."
},
{
"@type": "HowToStep",
"text": "Toss tofu cubes with cornstarch until evenly coated."
},
{
"@type": "HowToStep",
"text": "Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside."
},
{
"@type": "HowToStep",
"text": "In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant."
},
{
"@type": "HowToStep",
"text": "Add bell pepper and cook for 2-3 minutes until slightly softened."
},
{
"@type": "HowToStep",
"text": "Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer."
},
{
"@type": "HowToStep",
"text": "Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes."
},
{
"@type": "HowToStep",
"text": "Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles."
}
],
"nutrition": {
"@type": "NutritionInformation",
"calories": "280 kcal",
"proteinContent": "18 g",
"fatContent": "16 g",
"carbohydrateContent": "18 g",
"fiberContent": "3 g",
"servingSize": "1 serving"
}
}
</script>
</head>
<body>
<article>
<h1>Black Pepper Tofu</h1>
<p class="author">By Yotam Ottolenghi</p>
<p class="description">
A flavorful black pepper tofu dish with aromatic spices and crispy texture.
Inspired by Yotam Ottolenghi's signature style.
</p>
<div class="recipe-meta">
<p><strong>Prep Time:</strong> 15 minutes</p>
<p><strong>Cook Time:</strong> 20 minutes</p>
<p><strong>Total Time:</strong> 35 minutes</p>
<p><strong>Servings:</strong> 4</p>
</div>
<h2>Ingredients</h2>
<ul>
<li>400g firm tofu, pressed and cubed</li>
<li>2 tablespoons black peppercorns, coarsely ground</li>
<li>3 tablespoons soy sauce</li>
<li>2 tablespoons rice vinegar</li>
<li>1 tablespoon maple syrup</li>
<li>2 tablespoons cornstarch</li>
<li>3 tablespoons vegetable oil</li>
<li>4 cloves garlic, minced</li>
<li>1 tablespoon fresh ginger, grated</li>
<li>2 spring onions, sliced</li>
<li>1 red bell pepper, sliced</li>
<li>Sesame seeds for garnish</li>
</ul>
<h2>Instructions</h2>
<ol>
<li>Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.</li>
<li>Toss tofu cubes with cornstarch until evenly coated.</li>
<li>Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside.</li>
<li>In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.</li>
<li>Add bell pepper and cook for 2-3 minutes until slightly softened.</li>
<li>Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.</li>
<li>Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.</li>
<li>Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.</li>
</ol>
<h2>Nutrition Information</h2>
<p>Per serving: 280 calories, 18g protein, 16g fat, 18g carbohydrates, 3g fiber</p>
</article>
</body>
</html>
+534
View File
@@ -0,0 +1,534 @@
# OAuth Multi-User Load Testing Framework
Comprehensive multi-user benchmarking system for testing OAuth-authenticated Nextcloud MCP server with realistic collaborative workflows.
## Quick Start
```bash
# 1. Ensure docker-compose is running
docker-compose up -d
# 2. Run a benchmark with 2 users for 30 seconds
uv run python -m tests.load.oauth_benchmark --users 2 --duration 30
# 3. Clean up test users (IMPORTANT - always run after benchmark)
uv run python -m tests.load.cleanup_loadtest_users
# Optional: Verify cleanup
uv run python -m tests.load.cleanup_loadtest_users --dry-run
```
## Overview
This framework extends the basic load testing infrastructure to support:
- **Multiple OAuth-authenticated users** running concurrently
- **Coordinated workflows** spanning multiple users (sharing, collaboration, permissions)
- **Per-user metrics** tracking individual user performance
- **Workflow-specific metrics** measuring cross-user operation latencies
- **Realistic scenarios** mimicking actual user collaboration patterns
- **Concurrent user creation** - all users created and authenticated in parallel for fast setup
## Architecture
### Components
```
tests/load/
├── oauth_pool.py # OAuth user pool management
├── oauth_workloads.py # Multi-user workflow definitions
├── oauth_metrics.py # Enhanced metrics collection
├── oauth_benchmark.py # Main CLI entry point
└── README_OAUTH.md # This file
```
### Key Classes
**OAuthUserPool** (`oauth_pool.py`)
- Manages N OAuth-authenticated users
- Handles token acquisition and storage
- Creates and manages MCP sessions per user
- Tracks per-user operation statistics
**UserSessionWrapper** (`oauth_pool.py`)
- Wraps MCP ClientSession for a specific user
- Automatic operation tracking
- Convenient tool/resource access methods
**Workflow** (`oauth_workloads.py`)
- Base class for multi-user coordinated workflows
- Step-by-step execution with timing
- Comprehensive error handling and reporting
**OAuthBenchmarkMetrics** (`oauth_metrics.py`)
- Per-user operation counts and latencies
- Workflow completion rates and timings
- Baseline operation statistics
- Detailed reporting and JSON export
## Available Workflows
### 1. NoteShareWorkflow
**Scenario**: Alice creates a note and shares it with Bob, who then reads it.
**Steps**:
1. User A creates a note
2. User A shares note with User B (read-only permissions)
3. User B lists their shared notes (measures propagation delay)
4. User B reads the shared note
**Metrics**: Creation latency, share propagation time, read latency
### 2. CollaborativeEditWorkflow
**Scenario**: Multiple users concurrently edit the same note.
**Steps**:
1. Owner creates a note
2. All users read the note simultaneously
3. All users append content concurrently
4. Owner verifies final state
**Metrics**: Concurrent read latency, concurrent write conflicts, final state consistency
### 3. FileShareAndDownloadWorkflow
**Scenario**: Alice uploads a file, shares it with Bob, who then downloads it.
**Steps**:
1. User A creates a file via WebDAV
2. User A shares file with User B (read-only)
3. User B lists their shares
4. User B downloads the file
**Metrics**: Upload latency, share creation, download latency
### 4. MixedOAuthWorkload
**Distribution**:
- 50% Baseline operations (individual user CRUD)
- 30% Note sharing workflows
- 15% Collaborative editing workflows
- 5% File sharing workflows
## Usage
### Basic Usage
```bash
# 4 users, 60-second test with mixed workload
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
# 10 users, 5-minute test
uv run python -m tests.load.oauth_benchmark -u 10 -d 300
# Export results to JSON
uv run python -m tests.load.oauth_benchmark -u 5 -d 120 --output results.json
```
### Advanced Options
```bash
# Sharing-focused workload
uv run python -m tests.load.oauth_benchmark --workload sharing -u 8 -d 180
# Collaborative editing workload
uv run python -m tests.load.oauth_benchmark --workload collaboration -u 6 -d 120
# Baseline operations only (no workflows)
uv run python -m tests.load.oauth_benchmark --workload baseline -u 10 -d 60
# Verbose logging for debugging
uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --verbose
```
### CLI Options
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--users` | `-u` | 2 | Number of concurrent users (dynamically created) |
| `--duration` | `-d` | 30.0 | Test duration in seconds |
| `--warmup` | `-w` | 5.0 | Warmup period before metrics collection (seconds) |
| `--url` | | `http://localhost:8001/mcp` | MCP OAuth server URL |
| `--output` | `-o` | None | JSON output file path |
| `--workload` | | `mixed` | Workload type: mixed, sharing, collaboration, baseline |
| `--user-prefix` | | `loadtest` | Prefix for dynamically created usernames |
| `--cleanup/--no-cleanup` | | `cleanup` | Delete created users after benchmark |
| `--browser` | | `chromium` | Playwright browser: firefox, chromium, webkit |
| `--headed` | | False | Run browser in headed mode (visible window) |
| `--verbose` | `-v` | False | Enable verbose logging |
## Test User Creation
The framework **dynamically creates test users** on-demand with OAuth authentication:
- **Naming**: Users are created with the pattern `{prefix}_user_{n}` (default: `loadtest_user_1`, `loadtest_user_2`, etc.)
- **Customization**: Use `--user-prefix` to change the prefix (e.g., `--user-prefix mytest``mytest_user_1`)
- **Scalability**: No limit on user count - create as many concurrent users as your system can handle
- **Credentials**: Each user gets a randomly generated secure password
- **OAuth Tokens**: All users authenticate via automated OAuth flow using Playwright
- **Cleanup**: Users are automatically deleted after the benchmark (disable with `--no-cleanup`)
**Example**: Running `--users 5` creates:
- `loadtest_user_1` (Display: Load Test User 1, Email: loadtest_user_1@benchmark.local)
- `loadtest_user_2` (Display: Load Test User 2, Email: loadtest_user_2@benchmark.local)
- `loadtest_user_3` (Display: Load Test User 3, Email: loadtest_user_3@benchmark.local)
- `loadtest_user_4` (Display: Load Test User 4, Email: loadtest_user_4@benchmark.local)
- `loadtest_user_5` (Display: Load Test User 5, Email: loadtest_user_5@benchmark.local)
## Metrics Output
### Console Report
```
================================================================================
OAUTH MULTI-USER BENCHMARK RESULTS
================================================================================
Duration: 120.45s
Total Users: 5
Total Workflows Executed: 312
Total Baseline Operations: 678
--------------------------------------------------------------------------------
WORKFLOW STATISTICS
--------------------------------------------------------------------------------
Workflow Total Success Rate P50 P95
--------------------------------------------------------------------------------
note_share 112 109 97.3% 0.2341s 0.4782s
collaborative_edit 65 61 93.8% 0.5123s 0.9234s
file_share 29 29 100.0% 0.3456s 0.6123s
--------------------------------------------------------------------------------
PER-USER STATISTICS
--------------------------------------------------------------------------------
User Total Ops Success Errors Rate P50
--------------------------------------------------------------------------------
loadtest_user_1 289 283 6 97.9% 0.2456s
loadtest_user_2 245 241 4 98.4% 0.2123s
loadtest_user_3 231 226 5 97.8% 0.2345s
loadtest_user_4 198 195 3 98.5% 0.2234s
loadtest_user_5 187 184 3 98.4% 0.2189s
--------------------------------------------------------------------------------
BASELINE OPERATIONS
--------------------------------------------------------------------------------
Total Operations: 678
Success Rate: 98.2%
Latency: min=0.0234s, p50=0.1234s, p95=0.3456s, max=0.8123s
================================================================================
```
### JSON Export
```json
{
"summary": {
"duration": 120.45,
"total_workflows": 312,
"total_baseline_ops": 678,
"total_users": 5
},
"workflows": {
"note_share": {
"total_executions": 112,
"successful_executions": 109,
"failed_executions": 3,
"success_rate": 97.3,
"latency": {
"min": 0.1234,
"max": 0.8765,
"mean": 0.2891,
"median": 0.2341,
"p90": 0.4123,
"p95": 0.4782,
"p99": 0.7234
},
"step_latencies": {
"create_note": {...},
"share_note": {...},
"list_shared_with_me": {...},
"read_shared_note": {...}
}
}
},
"users": {
"loadtest_user_1": {
"total_operations": 289,
"successful_operations": 283,
"failed_operations": 6,
"success_rate": 97.9,
"latency": {...},
"operations_breakdown": {...},
"errors_breakdown": {...}
},
"loadtest_user_2": {...},
"loadtest_user_3": {...},
"loadtest_user_4": {...},
"loadtest_user_5": {...}
},
"baseline": {...}
}
```
## Implementation Status
### ✅ Completed Components
**Framework:**
- OAuth user pool management with dynamic user creation
- User session wrappers with automatic tracking
- Workflow base classes and framework
- 3 example workflows (note share, collaborative edit, file share)
- Enhanced metrics with per-user and workflow tracking
- CLI interface with multiple workload options
- Comprehensive reporting (console + JSON)
**OAuth Integration:**
- ✅ Playwright browser automation for OAuth login
- ✅ OAuth callback server for auth code capture
- ✅ Token exchange with OIDC provider
- ✅ OAuth token injection into MCP sessions via Authorization headers
- ✅ Cancel scope error handling for reliable cleanup
- ✅ Dynamic user creation and deletion via Nextcloud Users API
**Implementation Details:**
The benchmark now successfully:
1. Creates Nextcloud users dynamically with unique passwords
2. Acquires OAuth tokens via automated Playwright browser flows
3. Creates MCP client sessions with proper `Authorization: Bearer {token}` headers
4. Executes coordinated multi-user workflows
5. Tracks per-user and per-workflow metrics
6. Provides standalone cleanup utility for test users
**Key Fix (oauth_pool.py:163-164)**:
```python
# Pass OAuth token as Authorization header
headers = {"Authorization": f"Bearer {profile.token}"}
streamable_context = streamablehttp_client(mcp_url, headers=headers)
```
## Creating Custom Workflows
### Example: Permission Escalation Workflow
```python
class PermissionEscalationWorkflow(Workflow):
"""Test sharing permission changes."""
def __init__(self):
super().__init__("permission_escalation")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires 2+ users")
owner, collaborator = users[0], users[1]
# Step 1: Owner creates note
create_result = await self._execute_step(
"create_note",
owner,
lambda: owner.call_tool("nc_notes_create_note", {...})
)
# Step 2: Share read-only
await self._execute_step(
"share_readonly",
owner,
lambda: owner.call_tool("nc_share_create", {
"permissions": 1 # Read-only
})
)
# Step 3: Upgrade to edit permissions
await self._execute_step(
"upgrade_permissions",
owner,
lambda: owner.call_tool("nc_share_update", {
"permissions": 15 # Read+update+create+delete
})
)
# Step 4: Collaborator edits
await self._execute_step(
"collaborator_edit",
collaborator,
lambda: collaborator.call_tool("nc_notes_update_note", {...})
)
return self._finish(success=True)
```
### Registering Custom Workflows
```python
# In oauth_workloads.py
class MixedOAuthWorkload:
def __init__(self, users: list[UserSessionWrapper]):
self.users = users
self.workflows = {
"note_share": NoteShareWorkflow(),
"collaborative_edit": CollaborativeEditWorkflow(),
"file_share": FileShareAndDownloadWorkflow(),
"permission_escalation": PermissionEscalationWorkflow(), # Add your workflow
}
```
## Performance Expectations
### Baseline Performance (basic auth, from existing benchmarks)
- **Throughput**: 50-200 RPS for mixed workload
- **Latency**: p50 <100ms, p95 <500ms, p99 <1000ms
### OAuth Multi-User Expectations
- **Lower throughput**: ~30-60% of baseline due to:
- OAuth token validation overhead
- Cross-user synchronization delays
- Workflow coordination overhead
- **Higher p99 latency**: Due to workflow step dependencies
- **Focus**: End-to-end workflow completion time more important than raw RPS
### Common Bottlenecks
1. **OAuth token validation**: Per-request overhead
2. **Share propagation**: Time for shares to become visible to recipients
3. **Concurrent edit conflicts**: ETags and conflict resolution
4. **Permission checks**: Cross-user access validation
## Best Practices
1. **Start Small**: Begin with 2-3 users to validate workflows
2. **Monitor Errors**: Watch for permission errors and conflicts
3. **Adjust Delays**: Tune sleep delays between operations based on server response
4. **Profile Workflows**: Use step latencies to identify bottlenecks
5. **Export Results**: Always export to JSON for historical comparison
## Performance Optimizations
### Concurrent User Creation
The benchmark creates and authenticates users **concurrently** for maximum performance:
**Step 5: User Creation & OAuth Authentication**
- All N users are created in parallel using `asyncio.gather()`
- Each user runs through the full OAuth flow simultaneously
- Multiple Playwright browser contexts operate independently
**Step 6: MCP Session Creation**
- All user sessions are created concurrently
- OAuth tokens passed as Authorization headers to each session
**Performance Impact:**
- **Sequential** (old): ~10-12s per user → 40-48s for 4 users
- **Concurrent** (new): ~12-15s total for 4 users (3-4x speedup!)
Example output showing concurrent execution:
```
Step 5/6: Creating 4 users and acquiring OAuth tokens...
(Running concurrently for faster setup)
[1/4] Creating user 'loadtest_user_1'...
[2/4] Creating user 'loadtest_user_2'...
[3/4] Creating user 'loadtest_user_3'...
[4/4] Creating user 'loadtest_user_4'...
✓ User 'loadtest_user_4' authenticated
✓ User 'loadtest_user_2' authenticated
✓ User 'loadtest_user_1' authenticated
✓ User 'loadtest_user_3' authenticated
✓ Successfully created and authenticated 4 users
```
**Implementation** (oauth_benchmark.py:402-437):
```python
# Create tasks for all users
tasks = [
create_user_task(i, browser, callback_server.auth_states)
for i in range(num_users)
]
# Run all concurrently
results = await asyncio.gather(*tasks, return_exceptions=True)
```
## Cleanup
**Important**: Due to asyncio scoping issues with the MCP client library, automatic cleanup in the benchmark's finally block may not execute reliably. Always use the cleanup utility after running benchmarks.
### Cleanup Utility (Recommended)
Use the cleanup utility to remove test users:
```bash
# Dry run - see what would be deleted
uv run python -m tests.load.cleanup_loadtest_users --dry-run
# Delete all loadtest users
uv run python -m tests.load.cleanup_loadtest_users
# Delete users with custom prefix
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
```
### Disable Automatic Cleanup
To keep test users after the benchmark for inspection:
```bash
uv run python -m tests.load.oauth_benchmark --users 2 --no-cleanup
```
## Troubleshooting
### Leftover Test Users
**Symptom**: Test users remain in Nextcloud after benchmark crashes
**Solution**: Run the cleanup utility:
```bash
uv run python -m tests.load.cleanup_loadtest_users
```
### "User X not in pool" Error
- Ensure user count doesn't exceed configured limits
- Check that user creation succeeded in previous steps
### CancelledError During Benchmark
**Symptom**: Error message like `'CancelledError' object has no attribute 'username'` appears in logs
**Cause**: Async task cancellation during benchmark shutdown or errors can cause race conditions in error handling
**Solution**: This has been mitigated with defensive error handling. The worker now:
- Catches `asyncio.CancelledError` specifically before general exceptions
- Logs cancellation gracefully without attempting to access potentially invalid state
- Re-raises the exception to allow proper cleanup chain
If you still see this error, it's likely harmless and occurs during shutdown. The benchmark results should still be valid.
### High Error Rates
- Increase delay between operations (`await asyncio.sleep()` in worker)
- Check OAuth token validity
- Verify MCP OAuth server is running and accessible (port 8001)
- Rebuild mcp-oauth container after code changes: `docker-compose up --build -d mcp-oauth`
### Workflows Failing
- Check step-by-step latencies to identify failing steps
- Verify users have correct permissions
- Review server logs for errors
### MCP Session Creation Fails (401 Unauthorized)
**Solution**: This issue has been fixed! OAuth tokens are now properly passed as Authorization headers when creating MCP sessions.
If you still see 401 errors:
- Rebuild the mcp-oauth container: `docker-compose up --build -d mcp-oauth`
- Verify OAuth tokens are being acquired successfully in verbose mode
- Check that the token hasn't expired (use shorter test durations during troubleshooting)
## Future Enhancements
- [x] Dynamic user creation (beyond 4 default users) - **COMPLETED**
- [x] OAuth token injection for MCP sessions - **COMPLETED**
- [x] Cancel scope error handling - **COMPLETED**
- [x] Concurrent user creation and authentication - **COMPLETED** (3-4x speedup!)
- [ ] Workflow templates for common patterns
- [ ] Real-time dashboard for live monitoring
- [ ] Historical comparison and regression detection
- [ ] Load ramping (gradual user increase)
- [ ] Geographic distribution simulation (latency injection)
- [ ] Improve cleanup reliability in finally block
+1
View File
@@ -0,0 +1 @@
"""Load testing utilities for Nextcloud MCP Server."""
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
Load testing benchmark for Nextcloud MCP Server.
Usage:
uv run python -m tests.load.benchmark --concurrency 10 --duration 30
uv run python -m tests.load.benchmark -c 50 -d 300 --output results.json
"""
import json
import logging
import signal
import statistics
import sys
import time
from collections import Counter
from contextlib import asynccontextmanager
from typing import Any
import anyio
import click
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from tests.load.workloads import MixedWorkload, OperationResult, WorkloadOperations
logging.basicConfig(
level=logging.WARNING, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s"
)
logger = logging.getLogger(__name__)
class BenchmarkMetrics:
"""Collect and analyze benchmark metrics."""
def __init__(self):
self.results: list[OperationResult] = []
self.start_time: float | None = None
self.end_time: float | None = None
self._operation_counts: Counter = Counter()
self._operation_errors: Counter = Counter()
def add_result(self, result: OperationResult):
"""Add a single operation result."""
self.results.append(result)
self._operation_counts[result.operation] += 1
if not result.success:
self._operation_errors[result.operation] += 1
def start(self):
"""Mark the start of the benchmark."""
self.start_time = time.time()
def stop(self):
"""Mark the end of the benchmark."""
self.end_time = time.time()
@property
def duration(self) -> float:
"""Total benchmark duration in seconds."""
if self.start_time is None or self.end_time is None:
return 0.0
return self.end_time - self.start_time
@property
def total_requests(self) -> int:
"""Total number of requests made."""
return len(self.results)
@property
def successful_requests(self) -> int:
"""Number of successful requests."""
return sum(1 for r in self.results if r.success)
@property
def failed_requests(self) -> int:
"""Number of failed requests."""
return sum(1 for r in self.results if not r.success)
@property
def error_rate(self) -> float:
"""Error rate as a percentage."""
if self.total_requests == 0:
return 0.0
return (self.failed_requests / self.total_requests) * 100
@property
def requests_per_second(self) -> float:
"""Average requests per second."""
if self.duration == 0:
return 0.0
return self.total_requests / self.duration
def latency_stats(self) -> dict[str, float]:
"""Calculate latency statistics."""
if not self.results:
return {
"min": 0.0,
"max": 0.0,
"mean": 0.0,
"median": 0.0,
"p90": 0.0,
"p95": 0.0,
"p99": 0.0,
}
durations = [r.duration for r in self.results]
sorted_durations = sorted(durations)
def percentile(data: list[float], p: float) -> float:
k = (len(data) - 1) * p
f = int(k)
c = f + 1
if c >= len(data):
return data[-1]
return data[f] + (k - f) * (data[c] - data[f])
return {
"min": min(durations),
"max": max(durations),
"mean": statistics.mean(durations),
"median": statistics.median(durations),
"p90": percentile(sorted_durations, 0.90),
"p95": percentile(sorted_durations, 0.95),
"p99": percentile(sorted_durations, 0.99),
}
def operation_breakdown(self) -> dict[str, dict[str, Any]]:
"""Get per-operation statistics."""
breakdown = {}
for op_name in self._operation_counts:
op_results = [r for r in self.results if r.operation == op_name]
op_durations = [r.duration for r in op_results if r.success]
if op_durations:
sorted_durations = sorted(op_durations)
p50 = statistics.median(sorted_durations)
p95_idx = int(len(sorted_durations) * 0.95)
p95 = sorted_durations[min(p95_idx, len(sorted_durations) - 1)]
else:
p50 = p95 = 0.0
breakdown[op_name] = {
"count": self._operation_counts[op_name],
"errors": self._operation_errors[op_name],
"success_rate": (
(self._operation_counts[op_name] - self._operation_errors[op_name])
/ self._operation_counts[op_name]
* 100
),
"p50_latency": p50,
"p95_latency": p95,
}
return breakdown
def to_dict(self) -> dict[str, Any]:
"""Convert metrics to dictionary for JSON export."""
return {
"summary": {
"duration": self.duration,
"total_requests": self.total_requests,
"successful_requests": self.successful_requests,
"failed_requests": self.failed_requests,
"error_rate": self.error_rate,
"requests_per_second": self.requests_per_second,
},
"latency": self.latency_stats(),
"operations": self.operation_breakdown(),
}
def print_report(self):
"""Print human-readable benchmark report."""
print("\n" + "=" * 80)
print("BENCHMARK RESULTS")
print("=" * 80)
print(f"\nDuration: {self.duration:.2f}s")
print(f"Total Requests: {self.total_requests}")
print(f"Successful: {self.successful_requests}")
print(f"Failed: {self.failed_requests}")
print(f"Error Rate: {self.error_rate:.2f}%")
print(f"Requests/Second: {self.requests_per_second:.2f}")
print("\n" + "-" * 80)
print("LATENCY (seconds)")
print("-" * 80)
latency = self.latency_stats()
print(f"Min: {latency['min']:.4f}s")
print(f"Mean: {latency['mean']:.4f}s")
print(f"Median: {latency['median']:.4f}s")
print(f"P90: {latency['p90']:.4f}s")
print(f"P95: {latency['p95']:.4f}s")
print(f"P99: {latency['p99']:.4f}s")
print(f"Max: {latency['max']:.4f}s")
print("\n" + "-" * 80)
print("OPERATION BREAKDOWN")
print("-" * 80)
print(
f"{'Operation':<25} {'Count':>8} {'Errors':>8} {'Success':>9} {'P50':>10} {'P95':>10}"
)
print("-" * 80)
breakdown = self.operation_breakdown()
for op_name, stats in sorted(breakdown.items()):
print(
f"{op_name:<25} {stats['count']:>8} {stats['errors']:>8} "
f"{stats['success_rate']:>8.1f}% {stats['p50_latency']:>9.4f}s {stats['p95_latency']:>9.4f}s"
)
print("=" * 80 + "\n")
@asynccontextmanager
async def create_mcp_session(url: str):
"""Create an MCP client session with proper cleanup."""
logger.info(f"Creating MCP client session for {url}")
streamable_context = streamablehttp_client(url)
session_context = None
try:
read_stream, write_stream, _ = await streamable_context.__aenter__()
session_context = ClientSession(read_stream, write_stream)
session = await session_context.__aenter__()
await session.initialize()
logger.info("MCP client session initialized")
yield session
finally:
if session_context is not None:
try:
await session_context.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing session: {e}")
try:
await streamable_context.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing streamable context: {e}")
async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
"""Wait for MCP server to be ready."""
logger.info(f"Waiting for MCP server at {url}...")
for attempt in range(1, max_attempts + 1):
try:
async with create_mcp_session(url) as session:
# Try to get capabilities
await session.read_resource("nc://capabilities")
logger.info("MCP server is ready")
return True
except Exception as e:
if attempt < max_attempts:
logger.debug(f"Attempt {attempt}/{max_attempts}: {e}")
await anyio.sleep(2)
else:
logger.error(f"MCP server not ready after {max_attempts} attempts")
return False
return False
async def benchmark_worker(
worker_id: int,
url: str,
duration: float,
metrics: BenchmarkMetrics,
stop_event: anyio.Event,
):
"""Single worker that runs operations for the specified duration."""
logger.info(f"Worker {worker_id} starting...")
try:
async with create_mcp_session(url) as session:
ops = WorkloadOperations(session)
workload = MixedWorkload(ops)
# Warmup
await workload.warmup(count=5)
# Run operations until duration expires or stop event is set
start_time = time.time()
operation_count = 0
while not stop_event.is_set():
if time.time() - start_time >= duration:
break
result = await workload.run_operation()
metrics.add_result(result)
operation_count += 1
# Small delay to prevent overwhelming the server
await anyio.sleep(0.01)
# Cleanup
await ops.cleanup()
logger.info(f"Worker {worker_id} completed {operation_count} operations")
except Exception as e:
logger.error(f"Worker {worker_id} error: {e}", exc_info=True)
async def run_benchmark(
url: str,
concurrency: int,
duration: float,
warmup: float = 5.0,
) -> BenchmarkMetrics:
"""Run the benchmark with specified parameters."""
metrics = BenchmarkMetrics()
stop_event = anyio.Event()
# Setup signal handlers for graceful shutdown
def signal_handler(sig, frame):
logger.warning("Received interrupt signal, stopping benchmark...")
stop_event.set()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print(
f"\nStarting benchmark with {concurrency} concurrent workers for {duration}s..."
)
print(f"Target: {url}")
print(f"Warmup period: {warmup}s\n")
# Warmup period
if warmup > 0:
print("Warming up...")
await anyio.sleep(warmup)
# Start metrics collection
metrics.start()
# Create and run workers using anyio task groups
async with anyio.create_task_group() as tg:
# Start all workers
for i in range(concurrency):
tg.start_soon(benchmark_worker, i, url, duration, metrics, stop_event)
# Show progress
tg.start_soon(show_progress, duration, metrics, stop_event)
# Stop metrics (tasks already completed when task group exits)
metrics.stop()
return metrics
async def show_progress(
duration: float,
metrics: BenchmarkMetrics,
stop_event: anyio.Event,
):
"""Show real-time progress during benchmark."""
start_time = time.time()
while not stop_event.is_set():
elapsed = time.time() - start_time
if elapsed >= duration:
break
# Calculate progress
progress = min(elapsed / duration * 100, 100)
rps = metrics.total_requests / max(elapsed, 0.1)
# Print progress bar
bar_length = 40
filled = int(bar_length * progress / 100)
bar = "" * filled + "" * (bar_length - filled)
print(
f"\r[{bar}] {progress:5.1f}% | "
f"Requests: {metrics.total_requests:6d} | "
f"RPS: {rps:6.1f} | "
f"Errors: {metrics.failed_requests:4d}",
end="",
flush=True,
)
await anyio.sleep(0.5)
print() # New line after progress
@click.command()
@click.option(
"--concurrency",
"-c",
type=int,
default=10,
show_default=True,
help="Number of concurrent workers",
)
@click.option(
"--duration",
"-d",
type=float,
default=30.0,
show_default=True,
help="Test duration in seconds",
)
@click.option(
"--warmup",
"-w",
type=float,
default=5.0,
show_default=True,
help="Warmup duration before collecting metrics (seconds)",
)
@click.option(
"--url",
"-u",
default="http://localhost:8000/mcp",
show_default=True,
help="MCP server URL",
)
@click.option(
"--output",
"-o",
type=click.Path(),
help="Output file for JSON results (optional)",
)
@click.option(
"--wait-for-server/--no-wait",
default=True,
show_default=True,
help="Wait for MCP server to be ready before starting",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Enable verbose logging",
)
def main(
concurrency: int,
duration: float,
warmup: float,
url: str,
output: str | None,
wait_for_server: bool,
verbose: bool,
):
"""
Load testing benchmark for Nextcloud MCP Server.
Runs a mixed workload of realistic MCP operations against the server
and reports detailed performance metrics.
Examples:
# Quick 30-second test with 10 workers
uv run python -m tests.load.benchmark --concurrency 10 --duration 30
# Extended test with 50 workers for 5 minutes
uv run python -m tests.load.benchmark -c 50 -d 300
# Export results to JSON
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://localhost:8001/mcp
"""
if verbose:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("tests.load").setLevel(logging.DEBUG)
async def run():
# Wait for server if requested
if wait_for_server:
if not await wait_for_mcp_server(url):
print("ERROR: MCP server is not ready", file=sys.stderr)
sys.exit(1)
# Run benchmark
metrics = await run_benchmark(url, concurrency, duration, warmup)
# Print report
metrics.print_report()
# Export to JSON if requested
if output:
with open(output, "w") as f:
json.dump(metrics.to_dict(), f, indent=2)
print(f"Results exported to: {output}")
try:
anyio.run(run)
except KeyboardInterrupt:
print("\nBenchmark interrupted by user")
sys.exit(130)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
if verbose:
raise
sys.exit(1)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Cleanup utility for loadtest users.
Searches for and deletes all users with 'loadtest' prefix in their username.
Useful for cleaning up after failed benchmark runs.
Usage:
uv run python -m tests.load.cleanup_loadtest_users
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
uv run python -m tests.load.cleanup_loadtest_users --dry-run
"""
import sys
import anyio
import click
from nextcloud_mcp_server.client import NextcloudClient
async def cleanup_users(prefix: str = "loadtest", dry_run: bool = False):
"""
Search for and delete users with the specified prefix.
Args:
prefix: Username prefix to search for
dry_run: If True, only list users without deleting them
"""
print(f"Searching for users with prefix '{prefix}'...")
try:
client = NextcloudClient.from_env()
users = await client.users.search_users(search=prefix)
if not users:
print(f"✓ No users found with prefix '{prefix}'")
return
print(f"Found {len(users)} user(s): {', '.join(users)}\n")
if dry_run:
print("DRY RUN - No users will be deleted")
for user in users:
print(f" Would delete: {user}")
print("\nTo actually delete these users, run without --dry-run flag")
return
# Delete users
deleted = []
failed = []
for user in users:
try:
print(f" Deleting {user}...")
await client.users.delete_user(userid=user)
deleted.append(user)
print(f" ✓ Deleted {user}")
except Exception as e:
failed.append((user, str(e)))
print(f" ✗ Failed to delete {user}: {e}")
# Summary
print(f"\n{'=' * 60}")
print("Cleanup Summary")
print(f"{'=' * 60}")
print(f"Successfully deleted: {len(deleted)}")
print(f"Failed to delete: {len(failed)}")
if failed:
print("\nFailed deletions:")
for user, error in failed:
print(f" - {user}: {error}")
sys.exit(1)
else:
print("\n✓ All users cleaned up successfully")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
@click.command()
@click.option(
"--prefix",
default="loadtest",
show_default=True,
help="Username prefix to search for",
)
@click.option(
"--dry-run",
is_flag=True,
help="List users without deleting them",
)
def main(prefix: str, dry_run: bool):
"""
Cleanup loadtest users from Nextcloud.
Searches for all users with the specified prefix and deletes them.
Useful for cleaning up after failed benchmark runs.
Examples:
# Dry run to see what would be deleted
uv run python -m tests.load.cleanup_loadtest_users --dry-run
# Delete all loadtest users
uv run python -m tests.load.cleanup_loadtest_users
# Delete users with custom prefix
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
"""
anyio.run(cleanup_users, prefix, dry_run)
if __name__ == "__main__":
main()
+768
View File
@@ -0,0 +1,768 @@
#!/usr/bin/env python3
"""
OAuth Multi-User Load Testing for Nextcloud MCP Server.
Simulates realistic multi-user scenarios with coordinated workflows
like note sharing, collaborative editing, and file operations.
Usage:
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
uv run python -m tests.load.oauth_benchmark -u 10 -d 300 --workload sharing
"""
import json
import logging
import os
import secrets
import signal
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
import anyio
import click
import httpx
from playwright.async_api import async_playwright
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
from nextcloud_mcp_server.client import NextcloudClient
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
from tests.load.oauth_pool import (
OAuthUserPool,
UserSessionWrapper,
generate_secure_password,
)
from tests.load.oauth_workloads import MixedOAuthWorkload, WorkflowResult
logging.basicConfig(
level=logging.WARNING, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s"
)
logger = logging.getLogger(__name__)
class OAuthCallbackServer:
"""
Temporary HTTP server to capture OAuth authorization codes.
Runs in a background thread, captures auth codes via state parameter
correlation, and stores them in a shared dictionary.
"""
def __init__(self, host: str = "localhost", port: int = 8081):
self.host = host
self.port = port
self.auth_states: dict[str, str] = {}
self.server: HTTPServer | None = None
self.thread: threading.Thread | None = None
def start(self):
"""Start the callback server in a background thread."""
class CallbackHandler(BaseHTTPRequestHandler):
auth_states = self.auth_states
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/callback":
params = parse_qs(parsed.query)
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
if code and state:
self.auth_states[state] = code
logger.info(f"Captured auth code for state {state[:16]}...")
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h1>Authorization successful!</h1>"
b"<p>You can close this window.</p></body></html>"
)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
# Suppress default logging
pass
self.server = HTTPServer((self.host, self.port), CallbackHandler)
def run():
logger.info(f"OAuth callback server listening on {self.host}:{self.port}")
self.server.serve_forever()
self.thread = threading.Thread(target=run, daemon=True)
self.thread.start()
logger.info("OAuth callback server started")
def stop(self):
"""Stop the callback server."""
if self.server:
self.server.shutdown()
logger.info("OAuth callback server stopped")
def get_auth_code(self, state: str) -> str | None:
"""Get auth code for a given state parameter."""
return self.auth_states.get(state)
async def discover_oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
"""
Discover OIDC endpoints from Nextcloud's .well-known configuration.
Args:
nextcloud_host: Nextcloud host URL (e.g., http://localhost:8080)
Returns:
Dict with authorization_endpoint, token_endpoint, and registration_endpoint
"""
logger.info("Discovering OIDC endpoints...")
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
response = await client.get(
f"{nextcloud_host}/.well-known/openid-configuration"
)
response.raise_for_status()
config = response.json()
endpoints = {
"authorization_endpoint": config["authorization_endpoint"],
"token_endpoint": config["token_endpoint"],
"registration_endpoint": config["registration_endpoint"],
}
logger.info(f"Discovered endpoints: {endpoints}")
return endpoints
async def setup_oauth_client(
nextcloud_host: str, callback_url: str, registration_endpoint: str
) -> dict[str, str]:
"""
Setup OAuth client using load_or_register_client.
Args:
nextcloud_host: Nextcloud host URL
callback_url: OAuth callback URL
registration_endpoint: OAuth registration endpoint URL
Returns:
Dict with client_id and client_secret
"""
logger.info("Setting up OAuth client...")
# Use the client registration utility
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=".nextcloud_oauth_benchmark_client.json",
client_name="OAuth Benchmark Test Client",
redirect_uris=[callback_url],
)
logger.info(f"OAuth client setup complete (client_id: {client_info.client_id})")
return {
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
}
async def create_and_authenticate_user(
user_pool: OAuthUserPool,
browser: Any,
auth_states: dict[str, str],
username: str,
password: str,
display_name: str | None = None,
) -> str:
"""
Create Nextcloud user and acquire OAuth token via Playwright.
Args:
user_pool: OAuthUserPool instance
browser: Playwright browser instance
auth_states: Shared auth_states dict for callback server
username: Username to create
password: Password for the user
display_name: Optional display name
Returns:
OAuth access token for the user
"""
logger.info(f"Creating and authenticating user: {username}")
# Create Nextcloud user
await user_pool.create_nextcloud_user(
username=username,
password=password,
display_name=display_name or username,
)
# Generate unique state for this OAuth flow
state = secrets.token_urlsafe(32)
# Acquire OAuth token via Playwright
token = await user_pool.acquire_token_playwright(
browser=browser,
username=username,
password=password,
state=state,
auth_states=auth_states,
)
logger.info(f"Successfully authenticated user: {username}")
return token
async def oauth_benchmark_worker(
user_wrapper: UserSessionWrapper,
workload: MixedOAuthWorkload,
duration: float,
metrics: OAuthBenchmarkMetrics,
stop_event: anyio.Event,
):
"""
Single worker executing operations for one user.
Args:
user_wrapper: UserSessionWrapper for this worker
workload: MixedOAuthWorkload instance
duration: Test duration in seconds
metrics: Metrics collector
stop_event: Event to signal stop
"""
logger.info(f"Worker for {user_wrapper.username} starting...")
start_time = time.time()
operation_count = 0
try:
while not stop_event.is_set():
if time.time() - start_time >= duration:
break
# Run an operation (might be baseline or workflow)
result = await workload.run_operation()
# Record metrics
if isinstance(result, WorkflowResult):
metrics.add_workflow_result(result)
else:
# Baseline operation
metrics.add_baseline_operation(result)
operation_count += 1
# Small delay to prevent overwhelming the server
await anyio.sleep(0.05)
logger.info(
f"Worker for {user_wrapper.username} completed {operation_count} operations"
)
except anyio.get_cancelled_exc_class():
# Handle task cancellation gracefully (e.g., during benchmark shutdown)
logger.info(
f"Worker for {user_wrapper.username} was cancelled "
f"(completed {operation_count} operations)"
)
raise # Re-raise to allow proper cleanup
except Exception as e:
logger.error(f"Worker {user_wrapper.username} error: {e}", exc_info=True)
async def show_progress(
duration: float,
metrics: OAuthBenchmarkMetrics,
stop_event: anyio.Event,
):
"""Show real-time progress during benchmark."""
start_time = time.time()
while not stop_event.is_set():
elapsed = time.time() - start_time
if elapsed >= duration:
break
# Calculate progress
progress = min(elapsed / duration * 100, 100)
total_ops = len(metrics.baseline_operations) + len(metrics.workflows)
workflows = len(metrics.workflows)
# Print progress bar
bar_length = 40
filled = int(bar_length * progress / 100)
bar = "" * filled + "" * (bar_length - filled)
print(
f"\r[{bar}] {progress:5.1f}% | "
f"Total Ops: {total_ops:6d} | "
f"Workflows: {workflows:4d}",
end="",
flush=True,
)
await anyio.sleep(0.5)
print() # New line after progress
async def run_oauth_benchmark(
num_users: int,
duration: float,
mcp_url: str,
warmup: float = 5.0,
user_prefix: str = "loadtest",
cleanup: bool = True,
browser_type: str = "firefox",
headed: bool = False,
) -> OAuthBenchmarkMetrics:
"""
Run the OAuth multi-user benchmark with dynamic user creation.
Args:
num_users: Number of concurrent users to create
duration: Test duration in seconds
mcp_url: MCP server URL
warmup: Warmup period in seconds
user_prefix: Prefix for generated usernames
cleanup: Whether to delete users after benchmark
browser_type: Playwright browser type (firefox, chromium, webkit)
headed: Whether to run browser in headed mode
Returns:
OAuthBenchmarkMetrics with results
"""
metrics = OAuthBenchmarkMetrics()
stop_event = anyio.Event()
created_users: list[str] = []
callback_server: OAuthCallbackServer | None = None
user_pool: OAuthUserPool | None = None
admin_client: NextcloudClient | None = None
# Setup signal handlers for graceful shutdown
def signal_handler(sig, frame):
logger.warning("Received interrupt signal, stopping benchmark...")
stop_event.set()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print(f"\n{'=' * 80}")
print("OAUTH MULTI-USER BENCHMARK")
print(f"{'=' * 80}")
print(f"Users: {num_users} | Duration: {duration}s | Warmup: {warmup}s")
print(f"Target: {mcp_url}")
print(f"User Prefix: {user_prefix} | Cleanup: {cleanup}")
print(f"Browser: {browser_type} | Headed: {headed}")
print(f"{'=' * 80}\n")
try:
# Get environment variables
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
callback_url = "http://localhost:8081/callback"
# Step 1: Start OAuth callback server
print("Step 1/6: Starting OAuth callback server...")
callback_server = OAuthCallbackServer(host="localhost", port=8081)
callback_server.start()
print("✓ Callback server listening on http://localhost:8081\n")
# Step 2: Discover OIDC endpoints
print("Step 2/6: Discovering OIDC endpoints...")
endpoints = await discover_oidc_endpoints(nextcloud_host)
print(f"✓ Authorization endpoint: {endpoints['authorization_endpoint']}")
print(f"✓ Token endpoint: {endpoints['token_endpoint']}")
print(f"✓ Registration endpoint: {endpoints['registration_endpoint']}\n")
# Step 3: Setup OAuth client
print("Step 3/6: Setting up OAuth client...")
oauth_credentials = await setup_oauth_client(
nextcloud_host, callback_url, endpoints["registration_endpoint"]
)
print(f"✓ OAuth client registered (ID: {oauth_credentials['client_id']})\n")
# Step 4: Create admin client and user pool
print("Step 4/6: Initializing admin client and user pool...")
admin_client = NextcloudClient.from_env()
user_pool = OAuthUserPool(
admin_client=admin_client,
client_id=oauth_credentials["client_id"],
client_secret=oauth_credentials["client_secret"],
callback_url=callback_url,
token_endpoint=endpoints["token_endpoint"],
authorization_endpoint=endpoints["authorization_endpoint"],
)
async with user_pool:
print("✓ User pool initialized\n")
# Step 5: Create users and acquire OAuth tokens (concurrently)
print(f"Step 5/6: Creating {num_users} users and acquiring OAuth tokens...")
print("(Running concurrently for faster setup)\n")
async def create_user_task(
i: int, browser, auth_states: dict
) -> tuple[str, str, str] | None:
"""Create and authenticate a single user. Returns (username, password, token) or None on failure."""
username = f"{user_prefix}_user_{i + 1}"
password = generate_secure_password(16)
print(f" [{i + 1}/{num_users}] Creating user '{username}'...")
try:
token = await create_and_authenticate_user(
user_pool=user_pool,
browser=browser,
auth_states=auth_states,
username=username,
password=password,
display_name=f"Load Test User {i + 1}",
)
print(f" ✓ User '{username}' authenticated\n")
return (username, password, token)
except Exception as e:
logger.error(f"Failed to create/authenticate user {username}: {e}")
return None
async with async_playwright() as p:
# Launch browser
browser_launcher = getattr(p, browser_type)
browser = await browser_launcher.launch(headless=not headed)
try:
# Create all users concurrently using anyio task groups
results = []
async def run_and_collect(i: int):
"""Wrapper to collect results from tasks."""
try:
result = await create_user_task(
i, browser, callback_server.auth_states
)
results.append(result)
except Exception as e:
logger.error(f"User creation task failed: {e}")
results.append(e)
async with anyio.create_task_group() as tg:
for i in range(num_users):
tg.start_soon(run_and_collect, i)
# Process results
for result in results:
if isinstance(result, Exception):
logger.error(f"User creation task failed: {result}")
continue
if result is None:
continue
username, password, token = result
await user_pool.add_user(
username=username, password=password, token=token
)
created_users.append(username)
finally:
await browser.close()
if not created_users:
raise RuntimeError("Failed to create any users")
print(
f"✓ Successfully created and authenticated {len(created_users)} users\n"
)
# Step 6: Create MCP sessions for each user (concurrently)
print("Step 6/6: Creating MCP sessions for users...")
user_wrappers = []
async with user_pool:
async def create_session_task(username: str) -> UserSessionWrapper | None:
"""Create MCP session for a user. Returns wrapper or None on failure."""
try:
session = await user_pool.create_user_session(username, mcp_url)
wrapper = UserSessionWrapper(username, session, user_pool)
print(f" ✓ Session created for '{username}'")
return wrapper
except Exception as e:
logger.error(f"Failed to create session for {username}: {e}")
return None
# Create all sessions concurrently using anyio task groups
session_results = []
async def run_and_collect_session(username: str):
"""Wrapper to collect session results from tasks."""
try:
result = await create_session_task(username)
session_results.append(result)
except Exception as e:
logger.error(f"Session creation task failed: {e}")
session_results.append(e)
async with anyio.create_task_group() as tg:
for username in created_users:
tg.start_soon(run_and_collect_session, username)
# Process results
for result in session_results:
if isinstance(result, Exception):
logger.error(f"Session creation task failed: {result}")
continue
if result is not None:
user_wrappers.append(result)
if not user_wrappers:
raise RuntimeError("Failed to create any user sessions")
print(f"✓ Created {len(user_wrappers)} MCP sessions\n")
# Warmup period
if warmup > 0:
print(f"Warmup period: {warmup}s...")
await anyio.sleep(warmup)
print()
# Start benchmark
print(f"{'=' * 80}")
print("STARTING BENCHMARK")
print(f"{'=' * 80}\n")
metrics.start()
# Create workload and workers using anyio task groups
workload = MixedOAuthWorkload(user_wrappers)
# Run workers with progress display
async with anyio.create_task_group() as tg:
# Start all workers
for wrapper in user_wrappers:
tg.start_soon(
oauth_benchmark_worker,
wrapper,
workload,
duration,
metrics,
stop_event,
)
# Show progress
tg.start_soon(show_progress, duration, metrics, stop_event)
# Tasks already completed when task group exits
metrics.stop()
print(f"\n{'=' * 80}")
print("BENCHMARK COMPLETE")
print(f"{'=' * 80}\n")
# Cleanup user sessions
print("Closing user sessions...")
await user_pool.close_all_sessions()
print("✓ All sessions closed\n")
except Exception as e:
logger.error(f"Benchmark error: {e}", exc_info=True)
# Don't re-raise here - we want cleanup to run
finally:
# Cleanup callback server
if callback_server:
try:
callback_server.stop()
logger.info("OAuth callback server stopped")
except Exception as e:
logger.warning(f"Error stopping callback server: {e}")
# Cleanup test users
if cleanup and created_users:
print(f"\nCleaning up {len(created_users)} test users...")
# Create a new admin client for cleanup (don't rely on the existing one)
try:
cleanup_client = NextcloudClient.from_env()
for username in created_users:
try:
await cleanup_client.users.delete_user(userid=username)
print(f" ✓ Deleted user '{username}'")
except Exception as e:
logger.warning(f"Failed to delete user {username}: {e}")
print("✓ Cleanup complete\n")
except Exception as e:
logger.error(f"Error during user cleanup: {e}")
print(
"⚠️ Failed to cleanup users. Please run cleanup script manually.\n"
)
elif created_users:
print(
f"\n⚠️ {len(created_users)} test users were NOT deleted (cleanup=False)"
)
print(f"Users: {', '.join(created_users)}\n")
return metrics
@click.command()
@click.option(
"--users",
"-u",
type=int,
default=2,
show_default=True,
help="Number of concurrent users to create dynamically",
)
@click.option(
"--duration",
"-d",
type=float,
default=30.0,
show_default=True,
help="Test duration in seconds",
)
@click.option(
"--warmup",
"-w",
type=float,
default=5.0,
show_default=True,
help="Warmup duration before collecting metrics (seconds)",
)
@click.option(
"--url",
default="http://localhost:8001/mcp",
show_default=True,
help="MCP OAuth server URL",
)
@click.option(
"--output",
"-o",
type=click.Path(),
help="Output file for JSON results (optional)",
)
@click.option(
"--workload",
type=click.Choice(["mixed", "sharing", "collaboration", "baseline"]),
default="mixed",
show_default=True,
help="Workload type to execute",
)
@click.option(
"--user-prefix",
default="loadtest",
show_default=True,
help="Prefix for dynamically created usernames",
)
@click.option(
"--cleanup/--no-cleanup",
default=True,
show_default=True,
help="Delete created users after benchmark",
)
@click.option(
"--browser",
type=click.Choice(["firefox", "chromium", "webkit"]),
default="firefox",
show_default=True,
help="Playwright browser type for OAuth automation",
)
@click.option(
"--headed",
is_flag=True,
help="Run browser in headed mode (visible window, useful for debugging)",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Enable verbose logging",
)
def main(
users: int,
duration: float,
warmup: float,
url: str,
output: str | None,
workload: str,
user_prefix: str,
cleanup: bool,
browser: str,
headed: bool,
verbose: bool,
):
"""
OAuth Multi-User Load Testing for Nextcloud MCP Server.
Dynamically creates N users, authenticates them via OAuth using Playwright
browser automation, and simulates realistic multi-user scenarios with
coordinated workflows like note sharing, collaborative editing, and file operations.
Examples:
# 2 users, 30-second test (default settings)
uv run python -m tests.load.oauth_benchmark
# 4 users, 60-second test with mixed workload
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
# 10 users, 5-minute sharing-focused test
uv run python -m tests.load.oauth_benchmark -u 10 -d 300 --workload sharing
# Export results to JSON
uv run python -m tests.load.oauth_benchmark -u 5 -d 120 --output results.json
# Custom user prefix and keep users after benchmark
uv run python -m tests.load.oauth_benchmark -u 3 --user-prefix mytest --no-cleanup
# Debug with visible browser (headed mode)
uv run python -m tests.load.oauth_benchmark -u 2 -d 10 --headed --verbose
Requirements:
- docker-compose up (mcp-oauth container running on port 8001)
- NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars set
- Playwright browser installed: uv run playwright install firefox
"""
if verbose:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("tests.load").setLevel(logging.DEBUG)
async def run():
# Run benchmark
metrics = await run_oauth_benchmark(
num_users=users,
duration=duration,
mcp_url=url,
warmup=warmup,
user_prefix=user_prefix,
cleanup=cleanup,
browser_type=browser,
headed=headed,
)
# Print report
metrics.print_report()
# Export to JSON if requested
if output:
with open(output, "w") as f:
json.dump(metrics.to_dict(), f, indent=2)
print(f"Results exported to: {output}")
try:
anyio.run(run)
except KeyboardInterrupt:
print("\nBenchmark interrupted by user")
sys.exit(130)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
if verbose:
raise
sys.exit(1)
if __name__ == "__main__":
main()
+329
View File
@@ -0,0 +1,329 @@
"""
Enhanced metrics collection for OAuth multi-user load testing.
Extends the base BenchmarkMetrics to track per-user statistics,
workflow completion rates, and cross-user operation latencies.
"""
import statistics
from collections import Counter, defaultdict
from typing import Any
from tests.load.oauth_workloads import WorkflowResult
class OAuthBenchmarkMetrics:
"""
Enhanced metrics for OAuth multi-user load testing.
Tracks:
- Per-user operation counts and latencies
- Workflow completion rates and timings
- Cross-user operation metrics
- Step-by-step workflow breakdowns
"""
def __init__(self):
# Base metrics
self.start_time: float | None = None
self.end_time: float | None = None
# Per-user tracking
self.user_operations: dict[str, list[dict[str, Any]]] = defaultdict(list)
self.user_operation_counts: dict[str, Counter] = defaultdict(Counter)
self.user_errors: dict[str, Counter] = defaultdict(Counter)
# Workflow tracking
self.workflows: list[WorkflowResult] = []
self.workflow_counts: Counter = Counter()
self.workflow_successes: Counter = Counter()
self.workflow_durations: dict[str, list[float]] = defaultdict(list)
# Baseline operations (non-workflow)
self.baseline_operations: list[dict[str, Any]] = []
def start(self):
"""Mark the start of the benchmark."""
import time
self.start_time = time.time()
def stop(self):
"""Mark the end of the benchmark."""
import time
self.end_time = time.time()
@property
def duration(self) -> float:
"""Total benchmark duration in seconds."""
if self.start_time is None or self.end_time is None:
return 0.0
return self.end_time - self.start_time
def add_workflow_result(self, result: WorkflowResult):
"""
Add a workflow execution result.
Args:
result: WorkflowResult from workflow execution
"""
self.workflows.append(result)
self.workflow_counts[result.workflow_name] += 1
if result.success:
self.workflow_successes[result.workflow_name] += 1
self.workflow_durations[result.workflow_name].append(result.total_duration)
# Track per-user operations from workflow steps
for step in result.steps:
self.user_operation_counts[step.user][step.step_name] += 1
if not step.success:
self.user_errors[step.user][step.step_name] += 1
self.user_operations[step.user].append(
{
"type": "workflow_step",
"workflow": result.workflow_name,
"step": step.step_name,
"success": step.success,
"duration": step.duration,
"error": step.error,
}
)
def add_baseline_operation(self, operation: dict[str, Any]):
"""
Add a baseline (non-workflow) operation result.
Args:
operation: Dict with keys: type, operation, user, success, duration, error (optional)
"""
self.baseline_operations.append(operation)
user = operation.get("user", "unknown")
op_name = operation.get("operation", "unknown")
success = operation.get("success", False)
self.user_operation_counts[user][op_name] += 1
if not success:
self.user_errors[user][op_name] += 1
self.user_operations[user].append(operation)
def get_user_stats(self) -> dict[str, dict[str, Any]]:
"""
Get per-user statistics.
Returns:
Dict mapping username to their stats
"""
stats = {}
for user, operations in self.user_operations.items():
total_ops = len(operations)
successful_ops = sum(1 for op in operations if op.get("success", False))
durations = [op["duration"] for op in operations if "duration" in op]
stats[user] = {
"total_operations": total_ops,
"successful_operations": successful_ops,
"failed_operations": total_ops - successful_ops,
"success_rate": (successful_ops / total_ops * 100)
if total_ops > 0
else 0.0,
"latency": self._calculate_latency_stats(durations),
"operations_breakdown": dict(self.user_operation_counts[user]),
"errors_breakdown": dict(self.user_errors[user]),
}
return stats
def get_workflow_stats(self) -> dict[str, dict[str, Any]]:
"""
Get workflow execution statistics.
Returns:
Dict mapping workflow name to its stats
"""
stats = {}
for workflow_name in self.workflow_counts:
total = self.workflow_counts[workflow_name]
successes = self.workflow_successes[workflow_name]
durations = self.workflow_durations[workflow_name]
# Calculate per-step latencies
step_latencies = defaultdict(list)
for workflow in self.workflows:
if workflow.workflow_name == workflow_name:
for step in workflow.steps:
if step.success:
step_latencies[step.step_name].append(step.duration)
step_stats = {}
for step_name, latencies in step_latencies.items():
if latencies:
step_stats[step_name] = self._calculate_latency_stats(latencies)
stats[workflow_name] = {
"total_executions": total,
"successful_executions": successes,
"failed_executions": total - successes,
"success_rate": (successes / total * 100) if total > 0 else 0.0,
"latency": self._calculate_latency_stats(durations),
"step_latencies": step_stats,
}
return stats
def get_baseline_stats(self) -> dict[str, Any]:
"""
Get statistics for baseline operations.
Returns:
Dict with baseline operation stats
"""
if not self.baseline_operations:
return {
"total_operations": 0,
"success_rate": 0.0,
"latency": self._calculate_latency_stats([]),
}
total = len(self.baseline_operations)
successes = sum(
1 for op in self.baseline_operations if op.get("success", False)
)
durations = [
op["duration"] for op in self.baseline_operations if "duration" in op
]
# Per-operation breakdown
operation_counts = Counter()
operation_errors = Counter()
for op in self.baseline_operations:
op_name = op.get("operation", "unknown")
operation_counts[op_name] += 1
if not op.get("success", False):
operation_errors[op_name] += 1
return {
"total_operations": total,
"successful_operations": successes,
"failed_operations": total - successes,
"success_rate": (successes / total * 100) if total > 0 else 0.0,
"latency": self._calculate_latency_stats(durations),
"operations_breakdown": dict(operation_counts),
"errors_breakdown": dict(operation_errors),
}
def _calculate_latency_stats(self, durations: list[float]) -> dict[str, float]:
"""Calculate latency statistics from a list of durations."""
if not durations:
return {
"min": 0.0,
"max": 0.0,
"mean": 0.0,
"median": 0.0,
"p90": 0.0,
"p95": 0.0,
"p99": 0.0,
}
sorted_durations = sorted(durations)
def percentile(data: list[float], p: float) -> float:
k = (len(data) - 1) * p
f = int(k)
c = f + 1
if c >= len(data):
return data[-1]
return data[f] + (k - f) * (data[c] - data[f])
return {
"min": min(durations),
"max": max(durations),
"mean": statistics.mean(durations),
"median": statistics.median(durations),
"p90": percentile(sorted_durations, 0.90),
"p95": percentile(sorted_durations, 0.95),
"p99": percentile(sorted_durations, 0.99),
}
def to_dict(self) -> dict[str, Any]:
"""Convert metrics to dictionary for JSON export."""
return {
"summary": {
"duration": self.duration,
"total_workflows": len(self.workflows),
"total_baseline_ops": len(self.baseline_operations),
"total_users": len(self.user_operations),
},
"workflows": self.get_workflow_stats(),
"baseline": self.get_baseline_stats(),
"users": self.get_user_stats(),
}
def print_report(self):
"""Print human-readable benchmark report."""
print("\n" + "=" * 80)
print("OAUTH MULTI-USER BENCHMARK RESULTS")
print("=" * 80)
# Summary
print(f"\nDuration: {self.duration:.2f}s")
print(f"Total Users: {len(self.user_operations)}")
print(f"Total Workflows Executed: {len(self.workflows)}")
print(f"Total Baseline Operations: {len(self.baseline_operations)}")
# Workflow Stats
if self.workflows:
print("\n" + "-" * 80)
print("WORKFLOW STATISTICS")
print("-" * 80)
print(
f"{'Workflow':<30} {'Total':>8} {'Success':>8} {'Rate':>8} {'P50':>10} {'P95':>10}"
)
print("-" * 80)
workflow_stats = self.get_workflow_stats()
for name, stats in sorted(workflow_stats.items()):
latency = stats["latency"]
print(
f"{name:<30} {stats['total_executions']:>8} "
f"{stats['successful_executions']:>8} "
f"{stats['success_rate']:>7.1f}% "
f"{latency['median']:>9.4f}s {latency['p95']:>9.4f}s"
)
# Per-User Stats
print("\n" + "-" * 80)
print("PER-USER STATISTICS")
print("-" * 80)
print(
f"{'User':<20} {'Total Ops':>10} {'Success':>10} {'Errors':>8} {'Rate':>8} {'P50':>10}"
)
print("-" * 80)
user_stats = self.get_user_stats()
for username, stats in sorted(user_stats.items()):
latency = stats["latency"]
print(
f"{username:<20} {stats['total_operations']:>10} "
f"{stats['successful_operations']:>10} "
f"{stats['failed_operations']:>8} "
f"{stats['success_rate']:>7.1f}% "
f"{latency['median']:>9.4f}s"
)
# Baseline Stats
if self.baseline_operations:
print("\n" + "-" * 80)
print("BASELINE OPERATIONS")
print("-" * 80)
baseline = self.get_baseline_stats()
print(f"Total Operations: {baseline['total_operations']}")
print(f"Success Rate: {baseline['success_rate']:.1f}%")
latency = baseline["latency"]
print(
f"Latency: min={latency['min']:.4f}s, p50={latency['median']:.4f}s, "
f"p95={latency['p95']:.4f}s, max={latency['max']:.4f}s"
)
print("=" * 80 + "\n")
+485
View File
@@ -0,0 +1,485 @@
"""
OAuth User Pool Management for Load Testing.
Manages multiple OAuth-authenticated users for realistic multi-user load testing scenarios.
"""
import asyncio
import logging
from dataclasses import dataclass
from typing import Any
import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
logger = logging.getLogger(__name__)
@dataclass
class UserConfig:
"""Configuration for a single test user."""
username: str
password: str
display_name: str
email: str
groups: list[str]
@dataclass
class UserProfile:
"""Profile for an OAuth-authenticated user."""
username: str
password: str
token: str
session: ClientSession | None = None
streamable_context: Any | None = None # Store for proper cleanup
operation_count: int = 0
error_count: int = 0
class OAuthUserPool:
"""
Manages a pool of OAuth-authenticated users for load testing.
Handles token acquisition, session management, and user lifecycle.
"""
def __init__(
self,
admin_client: Any, # NextcloudClient with admin credentials
client_id: str,
client_secret: str,
callback_url: str,
token_endpoint: str,
authorization_endpoint: str,
):
self.admin_client = admin_client # For user management
self.nextcloud_host = str(admin_client._client.base_url)
self.client_id = client_id
self.client_secret = client_secret
self.callback_url = callback_url
self.token_endpoint = token_endpoint
self.authorization_endpoint = authorization_endpoint
self.users: dict[str, UserProfile] = {}
self._http_client: httpx.AsyncClient | None = None
async def __aenter__(self):
"""Initialize HTTP client."""
self._http_client = httpx.AsyncClient(verify=False, timeout=30.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Cleanup HTTP client."""
if self._http_client:
await self._http_client.aclose()
async def acquire_token(self, username: str, password: str, auth_code: str) -> str:
"""
Exchange authorization code for OAuth access token.
Args:
username: Username for logging
password: Password (for logging/debugging)
auth_code: Authorization code from OAuth flow
Returns:
OAuth access token
"""
logger.info(f"Exchanging auth code for access token (user: {username})...")
if not self._http_client:
raise RuntimeError(
"HTTP client not initialized - use async context manager"
)
# Exchange authorization code for access token
token_response = await self._http_client.post(
self.token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": self.callback_url,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access token in response for {username}")
logger.info(f"Successfully acquired OAuth token for {username}")
return access_token
async def add_user(self, username: str, password: str, token: str) -> UserProfile:
"""
Add a user to the pool with their OAuth token.
Args:
username: Username
password: Password (for future re-auth if needed)
token: OAuth access token
Returns:
UserProfile for the added user
"""
if username in self.users:
logger.warning(f"User {username} already in pool, updating token")
profile = UserProfile(username=username, password=password, token=token)
self.users[username] = profile
logger.info(f"Added user {username} to pool (total: {len(self.users)})")
return profile
async def create_user_session(
self, username: str, mcp_url: str = "http://localhost:8001/mcp"
) -> ClientSession:
"""
Create an MCP client session for a user.
Args:
username: Username to create session for
mcp_url: MCP server URL
Returns:
Initialized ClientSession
Raises:
KeyError: If user not in pool
"""
if username not in self.users:
raise KeyError(f"User {username} not in pool")
profile = self.users[username]
# Create streamable HTTP connection with OAuth token in Authorization header
# This matches the pattern from tests/conftest.py create_mcp_client_session()
headers = {"Authorization": f"Bearer {profile.token}"}
streamable_context = streamablehttp_client(mcp_url, headers=headers)
try:
read_stream, write_stream, _ = await streamable_context.__aenter__()
session = ClientSession(read_stream, write_stream)
await session.__aenter__()
await session.initialize()
# Store both session and context for proper cleanup
profile.session = session
profile.streamable_context = streamable_context
logger.info(f"Created MCP session for {username}")
return session
except Exception as e:
# Clean up streamable context if session creation failed
try:
await streamable_context.__aexit__(None, None, None)
except Exception as cleanup_error:
logger.debug(f"Error during cleanup: {cleanup_error}")
raise e
async def close_user_session(self, username: str):
"""Close the MCP session for a user."""
if username not in self.users:
return
profile = self.users[username]
# Close ClientSession
if profile.session:
try:
await profile.session.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing session for {username}: {e}")
profile.session = None
# Close streamable context
if profile.streamable_context:
try:
await profile.streamable_context.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing streamable context for {username}: {e}")
profile.streamable_context = None
async def close_all_sessions(self):
"""Close all user sessions."""
for username in list(self.users.keys()):
await self.close_user_session(username)
def get_user(self, username: str) -> UserProfile:
"""Get user profile by username."""
if username not in self.users:
raise KeyError(f"User {username} not in pool")
return self.users[username]
def get_all_users(self) -> list[UserProfile]:
"""Get all user profiles."""
return list(self.users.values())
def record_operation(self, username: str, success: bool = True):
"""Record an operation for user stats."""
if username in self.users:
self.users[username].operation_count += 1
if not success:
self.users[username].error_count += 1
def get_stats(self) -> dict[str, dict[str, int | float]]:
"""Get per-user operation statistics."""
return {
username: {
"operations": profile.operation_count,
"errors": profile.error_count,
"success_rate": (
(profile.operation_count - profile.error_count)
/ max(profile.operation_count, 1)
* 100
),
}
for username, profile in self.users.items()
}
async def create_nextcloud_user(
self,
username: str,
password: str,
display_name: str | None = None,
email: str | None = None,
) -> UserConfig:
"""
Create a Nextcloud user via the Users API.
Args:
username: Username for the new user
password: Password for the new user
display_name: Optional display name
email: Optional email address
Returns:
UserConfig for the created user
Raises:
HTTPStatusError: If user creation fails
"""
logger.info(f"Creating Nextcloud user: {username}")
await self.admin_client.users.create_user(
userid=username,
password=password,
display_name=display_name or username,
email=email or f"{username}@benchmark.local",
)
logger.info(f"Successfully created Nextcloud user: {username}")
return UserConfig(
username=username,
password=password,
display_name=display_name or username,
email=email or f"{username}@benchmark.local",
groups=[],
)
async def delete_nextcloud_user(self, username: str):
"""
Delete a Nextcloud user via the Users API.
Args:
username: Username to delete
"""
logger.info(f"Deleting Nextcloud user: {username}")
try:
await self.admin_client.users.delete_user(userid=username)
logger.info(f"Successfully deleted Nextcloud user: {username}")
except Exception as e:
logger.warning(f"Failed to delete user {username}: {e}")
async def acquire_token_playwright(
self,
browser: Any,
username: str,
password: str,
state: str,
auth_states: dict[str, str],
) -> str:
"""
Acquire OAuth token via Playwright browser automation.
Based on conftest.py playwright_oauth_token fixture.
Automates the full OAuth flow:
1. Navigate to authorization URL
2. Fill login form
3. Handle OAuth consent
4. Wait for callback server to receive auth code
5. Exchange code for access token
Args:
browser: Playwright browser instance
username: Username to authenticate
password: Password for the user
state: Unique state parameter for this OAuth flow
auth_states: Dict mapping state -> auth_code (shared with callback server)
Returns:
OAuth access token
Raises:
TimeoutError: If callback not received within timeout
ValueError: If token exchange fails
"""
import time
from urllib.parse import quote
logger.info(f"Starting Playwright OAuth flow for {username}...")
logger.debug(f"Using state: {state[:16]}...")
# Construct authorization URL
auth_url = (
f"{self.authorization_endpoint}?"
f"response_type=code&"
f"client_id={self.client_id}&"
f"redirect_uri={quote(self.callback_url, safe='')}&"
f"state={state}&"
f"scope=openid%20profile%20email"
)
# Browser automation
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Navigate to authorization URL
logger.debug("Navigating to authorization URL...")
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
current_url = page.url
# Login if needed
if "/login" in current_url or "/index.php/login" in current_url:
logger.info(f"Logging in as {username}...")
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=30000)
current_url = page.url
logger.info("Login completed")
# Handle OAuth consent if present
try:
authorize_button = await page.query_selector(
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
)
if authorize_button:
logger.info("Authorizing OAuth client...")
await authorize_button.click()
await page.wait_for_load_state("networkidle", timeout=10000)
except Exception as e:
logger.debug(f"No authorization needed: {e}")
# Wait for callback server to receive auth code
logger.info("Waiting for OAuth callback...")
timeout_seconds = 30
start_time = time.time()
while state not in auth_states:
if time.time() - start_time > timeout_seconds:
screenshot_path = f"/tmp/oauth_timeout_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(f"Screenshot saved to {screenshot_path}")
raise TimeoutError(
f"Timeout waiting for OAuth callback for {username}"
)
await asyncio.sleep(0.5)
auth_code = auth_states[state]
logger.info(f"Received auth code for {username}")
finally:
await context.close()
# Exchange code for token
logger.info(f"Exchanging auth code for access token ({username})...")
token_response = await self._http_client.post(
self.token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": self.callback_url,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access token for {username}: {token_data}")
logger.info(f"Successfully acquired OAuth token for {username}")
return access_token
class UserSessionWrapper:
"""
Wrapper for a user-specific MCP session with operation tracking.
Provides a convenient interface for executing operations as a specific user.
"""
def __init__(self, username: str, session: ClientSession, pool: OAuthUserPool):
self.username = username
self.session = session
self.pool = pool
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""
Call an MCP tool and record the operation.
Args:
tool_name: Name of the tool to call
arguments: Tool arguments
Returns:
Tool result
"""
try:
result = await self.session.call_tool(tool_name, arguments)
self.pool.record_operation(self.username, success=True)
return result
except Exception:
self.pool.record_operation(self.username, success=False)
raise
async def read_resource(self, uri: str) -> Any:
"""
Read an MCP resource and record the operation.
Args:
uri: Resource URI
Returns:
Resource data
"""
try:
result = await self.session.read_resource(uri)
self.pool.record_operation(self.username, success=True)
return result
except Exception:
self.pool.record_operation(self.username, success=False)
raise
def generate_secure_password(length: int = 20) -> str:
"""Generate a secure random password."""
import secrets
import string
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
return "".join(secrets.choice(alphabet) for _ in range(length))
+506
View File
@@ -0,0 +1,506 @@
"""
Multi-User Workflow Definitions for OAuth Load Testing.
Defines coordinated workflows that span multiple users, simulating realistic
collaborative scenarios like note sharing, file collaboration, and permission management.
"""
import asyncio
import json
import logging
import random
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from tests.load.oauth_pool import UserSessionWrapper
logger = logging.getLogger(__name__)
@dataclass
class WorkflowStepResult:
"""Result of a single workflow step."""
step_name: str
user: str
success: bool
duration: float
error: str | None = None
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class WorkflowResult:
"""Result of a complete workflow execution."""
workflow_name: str
success: bool
total_duration: float
steps: list[WorkflowStepResult]
participants: list[str]
error: str | None = None
@property
def steps_completed(self) -> int:
"""Count of successfully completed steps."""
return sum(1 for step in self.steps if step.success)
@property
def step_latencies(self) -> dict[str, float]:
"""Map of step names to their durations."""
return {step.step_name: step.duration for step in self.steps}
class Workflow(ABC):
"""
Base class for multi-user workflows.
A workflow represents a coordinated sequence of operations across multiple users,
such as creating and sharing a note, collaborative editing, or permission management.
"""
def __init__(self, name: str):
self.name = name
self.steps: list[WorkflowStepResult] = []
self.start_time: float | None = None
@abstractmethod
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""
Execute the workflow with the given users.
Args:
users: List of UserSessionWrapper instances to use in the workflow
Returns:
WorkflowResult with execution details
"""
pass
async def _execute_step(
self,
step_name: str,
user: UserSessionWrapper,
operation: Callable[..., Awaitable[Any]],
**kwargs,
) -> WorkflowStepResult:
"""
Execute a single workflow step with timing and error handling.
Args:
step_name: Name of the step for reporting
user: User executing the step
operation: Async callable to execute
**kwargs: Arguments to pass to the operation
Returns:
WorkflowStepResult
"""
start = time.time()
try:
result = await operation(**kwargs)
duration = time.time() - start
step_result = WorkflowStepResult(
step_name=step_name,
user=user.username,
success=True,
duration=duration,
data={"result": result} if result else {},
)
self.steps.append(step_result)
return step_result
except Exception as e:
duration = time.time() - start
logger.error(f"Step {step_name} failed for user {user.username}: {e}")
step_result = WorkflowStepResult(
step_name=step_name,
user=user.username,
success=False,
duration=duration,
error=str(e),
)
self.steps.append(step_result)
return step_result
def _finish(self, success: bool, error: str | None = None) -> WorkflowResult:
"""
Finalize workflow and create result.
Args:
success: Whether the overall workflow succeeded
error: Optional error message
Returns:
WorkflowResult
"""
duration = time.time() - self.start_time if self.start_time else 0.0
participants = list(set(step.user for step in self.steps))
return WorkflowResult(
workflow_name=self.name,
success=success,
total_duration=duration,
steps=self.steps,
participants=participants,
error=error,
)
class NoteShareWorkflow(Workflow):
"""
Workflow: User A creates a note and shares it with User B, who then reads it.
Steps:
1. User A creates a note
2. User A shares the note with User B (read-only)
3. User B lists their shared notes (verify propagation)
4. User B reads the shared note
"""
def __init__(self):
super().__init__("note_share")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""Execute note sharing workflow."""
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires at least 2 users")
user_a, user_b = users[0], users[1]
unique_id = uuid.uuid4().hex[:8]
try:
# Step 1: User A creates note
create_result = await self._execute_step(
"create_note",
user_a,
lambda: user_a.call_tool(
"nc_notes_create_note",
{
"title": f"Shared Note {unique_id}",
"content": f"Content for workflow test {unique_id}",
"category": "Workflows",
},
),
)
if not create_result.success:
return self._finish(False, error="Failed to create note")
# Extract note ID
note_data = json.loads(create_result.data["result"].content[0].text)
note_id = note_data["id"]
# Step 2: User A shares note with User B
# Note: Sharing files/notes requires using WebDAV path
# Create a file first, then share it
share_result = await self._execute_step(
"share_note",
user_a,
lambda: user_a.call_tool(
"nc_share_create",
{
"path": f"/Notes/{note_data['category']}/{note_data['title']}.txt",
"share_with": user_b.username,
"share_type": 0, # User share
"permissions": 1, # Read-only
},
),
)
if not share_result.success:
logger.warning("Share creation failed, continuing anyway")
# Step 3: User B lists shares (measure propagation)
await self._execute_step(
"list_shared_with_me",
user_b,
lambda: user_b.call_tool("nc_share_list", {"shared_with_me": True}),
)
# Step 4: User B reads the note
await self._execute_step(
"read_shared_note",
user_b,
lambda: user_b.call_tool("nc_notes_get_note", {"note_id": note_id}),
)
# Cleanup: Delete the note
await user_a.call_tool("nc_notes_delete_note", {"note_id": note_id})
return self._finish(success=True)
except Exception as e:
logger.error(f"Note share workflow failed: {e}")
return self._finish(False, error=str(e))
class CollaborativeEditWorkflow(Workflow):
"""
Workflow: Multiple users edit the same note concurrently.
Steps:
1. User A creates a note
2. User A shares note with Users B, C (edit permissions)
3. All users read the note simultaneously
4. All users update the note simultaneously (test concurrent edits)
5. User A verifies final state
"""
def __init__(self):
super().__init__("collaborative_edit")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""Execute collaborative editing workflow."""
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires at least 2 users")
owner = users[0]
collaborators = users[1:]
unique_id = uuid.uuid4().hex[:8]
try:
# Step 1: Owner creates note
create_result = await self._execute_step(
"create_note",
owner,
lambda: owner.call_tool(
"nc_notes_create_note",
{
"title": f"Collab Note {unique_id}",
"content": f"Initial content {unique_id}",
"category": "Collaboration",
},
),
)
if not create_result.success:
return self._finish(False, error="Failed to create note")
note_data = json.loads(create_result.data["result"].content[0].text)
note_id = note_data["id"]
# Step 2: Read note concurrently by all users
read_tasks = []
for i, user in enumerate(users):
read_tasks.append(
self._execute_step(
f"concurrent_read_{i}",
user,
lambda uid=note_id: user.call_tool(
"nc_notes_get_note", {"note_id": uid}
),
)
)
await asyncio.gather(*read_tasks)
# Step 3: Append content concurrently by all collaborators
append_tasks = []
for i, user in enumerate(collaborators):
append_tasks.append(
self._execute_step(
f"concurrent_append_{i}",
user,
lambda _=i, u=user: u.call_tool(
"nc_notes_append_content",
{
"note_id": note_id,
"content": f"Addition from {u.username} at {time.time()}",
},
),
)
)
await asyncio.gather(*append_tasks)
# Step 4: Owner verifies final state
await self._execute_step(
"verify_final_state",
owner,
lambda: owner.call_tool("nc_notes_get_note", {"note_id": note_id}),
)
# Cleanup
await owner.call_tool("nc_notes_delete_note", {"note_id": note_id})
return self._finish(success=True)
except Exception as e:
logger.error(f"Collaborative edit workflow failed: {e}")
return self._finish(False, error=str(e))
class FileShareAndDownloadWorkflow(Workflow):
"""
Workflow: User A uploads a file, shares it with User B, who then downloads it.
Steps:
1. User A creates a file via WebDAV
2. User A shares the file with User B (read-only)
3. User B lists their shares
4. User B reads/downloads the file
"""
def __init__(self):
super().__init__("file_share_download")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""Execute file sharing workflow."""
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires at least 2 users")
user_a, user_b = users[0], users[1]
unique_id = uuid.uuid4().hex[:8]
file_path = f"/LoadTest_{unique_id}.txt"
try:
# Step 1: User A creates a file
content = f"Test file content {unique_id}\nCreated for workflow testing"
create_result = await self._execute_step(
"create_file",
user_a,
lambda: user_a.call_tool(
"nc_webdav_put_file",
{
"path": file_path,
"content": content,
"content_type": "text/plain",
},
),
)
if not create_result.success:
return self._finish(False, error="Failed to create file")
# Step 2: User A shares file with User B
share_result = await self._execute_step(
"share_file",
user_a,
lambda: user_a.call_tool(
"nc_share_create",
{
"path": file_path,
"share_with": user_b.username,
"share_type": 0,
"permissions": 1, # Read-only
},
),
)
if not share_result.success:
logger.warning("File share failed, continuing")
# Step 3: User B lists shared files
_ = await self._execute_step(
"list_shares",
user_b,
lambda: user_b.call_tool("nc_share_list", {"shared_with_me": True}),
)
# Step 4: User B downloads the file
_ = await self._execute_step(
"download_file",
user_b,
lambda: user_b.call_tool("nc_webdav_get_file", {"path": file_path}),
)
# Cleanup
await user_a.call_tool("nc_webdav_delete", {"path": file_path})
return self._finish(success=True)
except Exception as e:
logger.error(f"File share workflow failed: {e}")
return self._finish(False, error=str(e))
class MixedOAuthWorkload:
"""
Mixed workload combining baseline operations and coordinated workflows.
Distribution:
- 50% Baseline operations (individual user CRUD)
- 30% Note sharing workflows
- 15% Collaborative editing workflows
- 5% File sharing workflows
"""
def __init__(self, users: list[UserSessionWrapper]):
self.users = users
self.workflows = {
"note_share": NoteShareWorkflow(),
"collaborative_edit": CollaborativeEditWorkflow(),
"file_share": FileShareAndDownloadWorkflow(),
}
async def run_operation(self) -> WorkflowResult | dict[str, Any]:
"""
Execute one random operation (baseline or workflow).
Returns:
WorkflowResult for workflows, dict for baseline operations
"""
rand = random.random()
# 50% baseline operations (single-user)
if rand < 0.50:
return await self._run_baseline_operation()
# 30% note sharing
elif rand < 0.80:
users = random.sample(self.users, min(2, len(self.users)))
return await self.workflows["note_share"].execute(users)
# 15% collaborative editing
elif rand < 0.95:
users = random.sample(self.users, min(len(self.users), 3))
return await self.workflows["collaborative_edit"].execute(users)
# 5% file sharing
else:
users = random.sample(self.users, min(2, len(self.users)))
return await self.workflows["file_share"].execute(users)
async def _run_baseline_operation(self) -> dict[str, Any]:
"""Run a baseline single-user operation."""
user = random.choice(self.users)
operations = [
(
"search_notes",
lambda: user.call_tool("nc_notes_search_notes", {"query": ""}),
),
("list_files", lambda: user.call_tool("nc_webdav_list", {"path": "/"})),
("get_capabilities", lambda: user.read_resource("nc://capabilities")),
]
op_name, operation = random.choice(operations)
start = time.time()
try:
await operation()
duration = time.time() - start
return {
"type": "baseline",
"operation": op_name,
"user": user.username,
"success": True,
"duration": duration,
}
except Exception as e:
duration = time.time() - start
return {
"type": "baseline",
"operation": op_name,
"user": user.username,
"success": False,
"duration": duration,
"error": str(e),
}
+282
View File
@@ -0,0 +1,282 @@
"""
Workload definitions for load testing the MCP server.
Defines realistic operation mixes and individual operation functions.
"""
import logging
import random
import time
import uuid
from mcp import ClientSession
logger = logging.getLogger(__name__)
class OperationResult:
"""Result of a single operation execution."""
def __init__(
self,
operation: str,
success: bool,
duration: float,
error: str | None = None,
):
self.operation = operation
self.success = success
self.duration = duration
self.error = error
self.timestamp = time.time()
class WorkloadOperations:
"""Collection of MCP operations for load testing."""
def __init__(self, session: ClientSession):
self.session = session
self._created_notes: list[int] = []
self._created_boards: list[int] = []
async def get_capabilities(self) -> OperationResult:
"""Fetch server capabilities (lightweight operation)."""
start = time.time()
try:
await self.session.read_resource("nc://capabilities")
duration = time.time() - start
return OperationResult("get_capabilities", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("get_capabilities", False, duration, str(e))
async def list_notes(self) -> OperationResult:
"""List all notes (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_search_notes", {"query": ""})
duration = time.time() - start
return OperationResult("list_notes", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_notes", False, duration, str(e))
async def search_notes(self, query: str = "test") -> OperationResult:
"""Search notes by query (read operation with filtering)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_search_notes", {"query": query})
duration = time.time() - start
return OperationResult("search_notes", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("search_notes", False, duration, str(e))
async def create_note(self) -> OperationResult:
"""Create a new note (write operation)."""
start = time.time()
unique_id = uuid.uuid4().hex[:8]
try:
result = await self.session.call_tool(
"nc_notes_create_note",
{
"title": f"Load Test Note {unique_id}",
"content": f"Content for load test note {unique_id}",
"category": "LoadTesting",
},
)
duration = time.time() - start
# Track created note ID for cleanup
if result and len(result.content) > 0:
content = result.content[0]
if hasattr(content, "text"):
import json
note_data = json.loads(content.text)
note_id = note_data.get("id")
if note_id:
self._created_notes.append(note_id)
return OperationResult("create_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("create_note", False, duration, str(e))
async def get_note(self, note_id: int) -> OperationResult:
"""Get a specific note by ID (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_get_note", {"note_id": note_id})
duration = time.time() - start
return OperationResult("get_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("get_note", False, duration, str(e))
async def update_note(self, note_id: int, etag: str) -> OperationResult:
"""Update an existing note (write operation)."""
start = time.time()
try:
await self.session.call_tool(
"nc_notes_update_note",
{
"note_id": note_id,
"etag": etag,
"title": f"Updated Note {note_id}",
"content": f"Updated content at {time.time()}",
"category": "LoadTesting",
},
)
duration = time.time() - start
return OperationResult("update_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("update_note", False, duration, str(e))
async def delete_note(self, note_id: int) -> OperationResult:
"""Delete a note (write operation)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_delete_note", {"note_id": note_id})
duration = time.time() - start
# Remove from tracking
if note_id in self._created_notes:
self._created_notes.remove(note_id)
return OperationResult("delete_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("delete_note", False, duration, str(e))
async def list_webdav_files(self, path: str = "/") -> OperationResult:
"""List files via WebDAV (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_webdav_list", {"path": path})
duration = time.time() - start
return OperationResult("list_webdav_files", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_webdav_files", False, duration, str(e))
async def list_calendars(self) -> OperationResult:
"""List calendars (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_calendar_list_calendars", {})
duration = time.time() - start
return OperationResult("list_calendars", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_calendars", False, duration, str(e))
async def list_deck_boards(self) -> OperationResult:
"""List deck boards (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_deck_list_boards", {})
duration = time.time() - start
return OperationResult("list_deck_boards", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_deck_boards", False, duration, str(e))
async def cleanup(self):
"""Clean up any resources created during testing."""
logger.info(f"Cleaning up {len(self._created_notes)} test notes...")
for note_id in self._created_notes[:]:
try:
await self.delete_note(note_id)
except Exception as e:
logger.warning(f"Failed to delete note {note_id}: {e}")
class MixedWorkload:
"""
Realistic mixed workload simulating typical MCP server usage.
Operation distribution:
- 40% Notes read (list/get/search)
- 20% Notes write (create/update/delete)
- 15% Notes search
- 10% WebDAV operations
- 10% Calendar operations
- 5% Other (capabilities, deck)
"""
def __init__(self, operations: WorkloadOperations):
self.ops = operations
# Pre-create some notes for read/update operations
self._warmup_note_ids: list[tuple[int, str]] = []
async def warmup(self, count: int = 10):
"""Create initial notes for read/update operations."""
logger.info(f"Warming up with {count} test notes...")
for _ in range(count):
result = await self.ops.create_note()
if result.success and self.ops._created_notes:
note_id = self.ops._created_notes[-1]
# Get the note to fetch its etag
try:
get_result = await self.ops.session.call_tool(
"nc_notes_get_note", {"note_id": note_id}
)
if get_result and len(get_result.content) > 0:
import json
note_data = json.loads(get_result.content[0].text)
etag = note_data.get("etag", "")
self._warmup_note_ids.append((note_id, etag))
except Exception as e:
logger.warning(f"Failed to get etag for note {note_id}: {e}")
async def run_operation(self) -> OperationResult:
"""Execute one random operation based on the workload distribution."""
rand = random.random()
# 40% reads (list/get/search)
if rand < 0.40:
op_rand = random.random()
if op_rand < 0.5:
return await self.ops.list_notes()
elif op_rand < 0.8 and self._warmup_note_ids:
note_id, _ = random.choice(self._warmup_note_ids)
return await self.ops.get_note(note_id)
else:
return await self.ops.search_notes()
# 20% writes (create/update/delete)
elif rand < 0.60:
op_rand = random.random()
if op_rand < 0.5:
return await self.ops.create_note()
elif op_rand < 0.8 and self._warmup_note_ids:
note_id, etag = random.choice(self._warmup_note_ids)
return await self.ops.update_note(note_id, etag)
elif self.ops._created_notes and len(self.ops._created_notes) > 5:
# Only delete if we have enough notes
note_id = random.choice(self.ops._created_notes)
return await self.ops.delete_note(note_id)
else:
return await self.ops.create_note()
# 15% search
elif rand < 0.75:
queries = ["test", "load", "note", "content", ""]
return await self.ops.search_notes(random.choice(queries))
# 10% WebDAV
elif rand < 0.85:
return await self.ops.list_webdav_files()
# 10% Calendar
elif rand < 0.95:
return await self.ops.list_calendars()
# 5% Other
else:
op_rand = random.random()
if op_rand < 0.5:
return await self.ops.get_capabilities()
else:
return await self.ops.list_deck_boards()
View File
+476
View File
@@ -0,0 +1,476 @@
"""Integration tests for Calendar VTODO (task) MCP tools."""
import logging
from datetime import datetime, timedelta
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
async def test_mcp_todo_complete_workflow(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test complete todo workflow via MCP tools with verification via NextcloudClient."""
calendar_name = temporary_calendar
todo_uid = None
try:
# 1. Create todo via MCP
logger.info(f"Creating todo in {calendar_name} via MCP")
tomorrow = datetime.now() + timedelta(days=1)
create_result = await nc_mcp_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": "MCP Test Task",
"description": "Test task created via MCP tools",
"status": "NEEDS-ACTION",
"priority": 3,
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
"categories": "testing,mcp",
},
)
assert create_result.isError is False
# Extract UID from the result
result_data = create_result.content[0].text
import json
result_json = json.loads(result_data)
todo_uid = result_json["uid"]
logger.info(f"Created todo with UID: {todo_uid}")
# 2. Verify todo creation via client
todos = await nc_client.calendar.list_todos(calendar_name)
assert any(t["uid"] == todo_uid for t in todos)
created_todo = next(t for t in todos if t["uid"] == todo_uid)
assert created_todo["summary"] == "MCP Test Task"
assert created_todo["status"] == "NEEDS-ACTION"
assert created_todo["priority"] == 3
# 3. List todos via MCP
logger.info(f"Listing todos in {calendar_name} via MCP")
list_result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name},
)
assert list_result.isError is False
list_data = json.loads(list_result.content[0].text)
assert "todos" in list_data
assert any(t["uid"] == todo_uid for t in list_data["todos"])
# 4. Update todo via MCP
logger.info(f"Updating todo {todo_uid} via MCP")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"summary": "MCP Test Task Updated",
"status": "IN-PROCESS",
"priority": 1,
"percent_complete": 50,
},
)
assert update_result.isError is False
# 5. Verify update via client
todos = await nc_client.calendar.list_todos(calendar_name)
updated_todo = next(t for t in todos if t["uid"] == todo_uid)
assert updated_todo["summary"] == "MCP Test Task Updated"
assert updated_todo["status"] == "IN-PROCESS"
assert updated_todo["priority"] == 1
assert updated_todo["percent_complete"] == 50
# 6. Delete todo via MCP
logger.info(f"Deleting todo {todo_uid} via MCP")
delete_result = await nc_mcp_client.call_tool(
"nc_calendar_delete_todo",
{"calendar_name": calendar_name, "todo_uid": todo_uid},
)
assert delete_result.isError is False
# 7. Verify deletion via client
todos = await nc_client.calendar.list_todos(calendar_name)
assert not any(t["uid"] == todo_uid for t in todos)
logger.info("Complete todo workflow test passed")
finally:
# Cleanup in case of failure
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_list_todos_with_filters(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test listing todos with various filters via MCP tools."""
calendar_name = temporary_calendar
created_uids = []
try:
# Create test todos with different properties
test_todos = [
{
"summary": "High Priority Task",
"status": "NEEDS-ACTION",
"priority": 1,
"categories": "urgent,work",
},
{
"summary": "In Progress Task",
"status": "IN-PROCESS",
"priority": 5,
"categories": "work",
},
{
"summary": "Low Priority Task",
"status": "NEEDS-ACTION",
"priority": 9,
"categories": "someday",
},
]
# Create todos via client
for todo_data in test_todos:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
created_uids.append(result["uid"])
# Test 1: Filter by status
logger.info("Testing filter by status")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
)
assert result.isError is False
import json
data = json.loads(result.content[0].text)
needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(needs_action_todos) == 2 # Two NEEDS-ACTION todos
# Test 2: Filter by priority
logger.info("Testing filter by minimum priority")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "min_priority": 1},
)
assert result.isError is False
data = json.loads(result.content[0].text)
high_priority_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(high_priority_todos) >= 1 # At least the priority 1 todo
# Test 3: Filter by categories
logger.info("Testing filter by categories")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "categories": "work"},
)
assert result.isError is False
data = json.loads(result.content[0].text)
work_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(work_todos) >= 2 # Two todos with "work" category
# Test 4: Filter by summary text
logger.info("Testing filter by summary text")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "summary_contains": "Priority"},
)
assert result.isError is False
data = json.loads(result.content[0].text)
priority_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(priority_todos) == 2 # Two have "Priority" in summary (High, Low)
logger.info("List todos with filters test passed")
finally:
# Cleanup
for uid in created_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
async def test_mcp_search_todos_across_calendars(
nc_mcp_client: ClientSession,
nc_client: NextcloudClient,
temporary_calendar: str,
shared_calendar_2: str,
):
"""Test searching todos across multiple calendars via MCP tools.
Note: Uses two shared test calendars to avoid rate limiting.
"""
cal1_name = temporary_calendar # First shared test calendar
cal2_name = shared_calendar_2 # Second shared test calendar
created_uids = []
try:
# Use existing shared calendars (no creation needed, avoiding rate limits)
# Create todos in both calendars
result1 = await nc_client.calendar.create_todo(
cal1_name,
{
"summary": "Task in Calendar 1",
"status": "NEEDS-ACTION",
"categories": "cal1",
},
)
created_uids.append((cal1_name, result1["uid"]))
result2 = await nc_client.calendar.create_todo(
cal2_name,
{
"summary": "Task in Calendar 2",
"status": "IN-PROCESS",
"categories": "cal2",
},
)
created_uids.append((cal2_name, result2["uid"]))
# Search across all calendars via MCP
logger.info("Searching todos across all calendars via MCP")
search_result = await nc_mcp_client.call_tool(
"nc_calendar_search_todos",
{},
)
assert search_result.isError is False
import json
data = json.loads(search_result.content[0].text)
assert "todos" in data
# Verify both todos are in the results
found_uids = {t["uid"] for t in data["todos"]}
assert result1["uid"] in found_uids
assert result2["uid"] in found_uids
# Verify calendar_name is included
our_todos = [
t for t in data["todos"] if t["uid"] in [result1["uid"], result2["uid"]]
]
for todo in our_todos:
assert "calendar_name" in todo
assert todo["calendar_name"] in [cal1_name, cal2_name]
# Test search with status filter
logger.info("Searching with status filter via MCP")
search_result = await nc_mcp_client.call_tool(
"nc_calendar_search_todos",
{"status": "IN-PROCESS"},
)
assert search_result.isError is False
data = json.loads(search_result.content[0].text)
in_process_todos = [
t for t in data["todos"] if t["uid"] in [uid for _, uid in created_uids]
]
assert len(in_process_todos) >= 1
logger.info("Search todos across calendars test passed")
finally:
# Cleanup: Only delete todos, not calendars (they're reused/built-in)
for cal_name, uid in created_uids:
try:
await nc_client.calendar.delete_todo(cal_name, uid)
except Exception:
pass
async def test_mcp_todo_status_transitions(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test transitioning through different todo statuses via MCP tools."""
calendar_name = temporary_calendar
todo_uid = None
try:
# Create todo
result = await nc_client.calendar.create_todo(
calendar_name,
{"summary": "Status Transition Test", "status": "NEEDS-ACTION"},
)
todo_uid = result["uid"]
# Transition: NEEDS-ACTION → IN-PROCESS
logger.info("Transitioning todo to IN-PROCESS via MCP")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"status": "IN-PROCESS",
"percent_complete": 25,
},
)
assert update_result.isError is False
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next(t for t in todos if t["uid"] == todo_uid)
assert todo["status"] == "IN-PROCESS"
assert todo["percent_complete"] == 25
# Transition: IN-PROCESS → COMPLETED
logger.info("Transitioning todo to COMPLETED via MCP")
completed_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"status": "COMPLETED",
"percent_complete": 100,
"completed": completed_time,
},
)
assert update_result.isError is False
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next(t for t in todos if t["uid"] == todo_uid)
assert todo["status"] == "COMPLETED"
assert todo["percent_complete"] == 100
assert "completed" in todo
logger.info("Todo status transitions test passed")
finally:
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_todo_with_dates(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating and managing todos with date fields via MCP tools."""
calendar_name = temporary_calendar
todo_uid = None
try:
now = datetime.now()
start_date = (now + timedelta(days=1)).strftime("%Y-%m-%dT09:00:00")
due_date = (now + timedelta(days=7)).strftime("%Y-%m-%dT17:00:00")
# Create todo with dates via MCP
logger.info("Creating todo with dates via MCP")
create_result = await nc_mcp_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": "Task with Dates",
"description": "Test task with various date fields",
"status": "NEEDS-ACTION",
"dtstart": start_date,
"due": due_date,
},
)
assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"]
# Verify dates via client
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next(t for t in todos if t["uid"] == todo_uid)
assert created_todo["summary"] == "Task with Dates"
assert "dtstart" in created_todo
assert "due" in created_todo
logger.info("Todo with dates test passed")
finally:
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_todo_categories(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating and managing todos with categories via MCP tools."""
calendar_name = temporary_calendar
todo_uid = None
try:
# Create todo with multiple categories via MCP
logger.info("Creating todo with categories via MCP")
create_result = await nc_mcp_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": "Task with Categories",
"status": "NEEDS-ACTION",
"categories": "work,meeting,important,quarterly",
},
)
assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"]
# Verify categories via client
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next(t for t in todos if t["uid"] == todo_uid)
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
# Update categories via MCP
logger.info("Updating todo categories via MCP")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"categories": "updated,new-category",
},
)
assert update_result.isError is False
# Verify updated categories
todos = await nc_client.calendar.list_todos(calendar_name)
updated_todo = next(t for t in todos if t["uid"] == todo_uid)
categories_str = updated_todo["categories"]
assert "updated" in categories_str
assert "new-category" in categories_str
logger.info("Todo categories test passed")
finally:
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
+554
View File
@@ -0,0 +1,554 @@
import asyncio
import json
import logging
import uuid
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
async def test_mcp_cookbook_create_and_read_recipe(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test creating and reading a recipe via MCP tools with verification via NextcloudClient."""
unique_suffix = uuid.uuid4().hex[:8]
recipe_name = f"MCP Test Recipe {unique_suffix}"
recipe_data = {
"name": recipe_name,
"description": "A test recipe created via MCP tools",
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
"recipeInstructions": ["Mix ingredients", "Cook for 20 minutes", "Serve hot"],
"recipeCategory": "MCPTesting",
"keywords": f"mcp,testing,{unique_suffix}",
"recipeYield": 4,
"prepTime": "PT15M",
"cookTime": "PT20M",
"totalTime": "PT35M",
}
created_recipe_id = None
try:
# 1. Create recipe via MCP
logger.info(f"Creating recipe via MCP: {recipe_name}")
create_result = await nc_mcp_client.call_tool(
"nc_cookbook_create_recipe",
{
"name": recipe_name,
"description": recipe_data["description"],
"ingredients": recipe_data["recipeIngredient"],
"instructions": recipe_data["recipeInstructions"],
"category": recipe_data["recipeCategory"],
"keywords": recipe_data["keywords"],
"recipe_yield": recipe_data["recipeYield"],
"prep_time": recipe_data["prepTime"],
"cook_time": recipe_data["cookTime"],
"total_time": recipe_data["totalTime"],
},
)
assert create_result.isError is False, (
f"MCP recipe creation failed: {create_result.content}"
)
create_response = json.loads(create_result.content[0].text)
created_recipe_id = create_response["id"]
logger.info(f"Recipe created via MCP with ID: {created_recipe_id}")
# 2. Verify creation via direct NextcloudClient
direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
assert direct_recipe["name"] == recipe_name
assert direct_recipe["description"] == "A test recipe created via MCP tools"
assert len(direct_recipe["recipeIngredient"]) == 3
assert len(direct_recipe["recipeInstructions"]) == 3
assert direct_recipe["recipeCategory"] == "MCPTesting"
# 3. Read recipe via MCP
logger.info(f"Reading recipe via MCP: {created_recipe_id}")
read_result = await nc_mcp_client.call_tool(
"nc_cookbook_get_recipe", {"recipe_id": created_recipe_id}
)
assert read_result.isError is False, (
f"MCP recipe read failed: {read_result.content}"
)
read_recipe = json.loads(read_result.content[0].text)
assert read_recipe["name"] == recipe_name
assert read_recipe["description"] == "A test recipe created via MCP tools"
assert len(read_recipe["recipeIngredient"]) == 3
logger.info(f"Successfully verified recipe {created_recipe_id} via MCP")
finally:
# Cleanup
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup recipe: {e}")
async def test_mcp_cookbook_update_recipe(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test updating a recipe via MCP tools."""
unique_suffix = uuid.uuid4().hex[:8]
recipe_name = f"MCP Update Test {unique_suffix}"
recipe_data = {
"name": recipe_name,
"description": "Original description",
"recipeIngredient": ["100g flour"],
"recipeInstructions": ["Mix ingredients"],
"recipeCategory": "Original",
}
created_recipe_id = None
try:
# 1. Create recipe via direct client
logger.info(f"Creating recipe for update test: {recipe_name}")
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
# 2. Update recipe via MCP (tool handles fetching current recipe internally)
logger.info(f"Updating recipe via MCP: {created_recipe_id}")
update_result = await nc_mcp_client.call_tool(
"nc_cookbook_update_recipe",
{
"recipe_id": created_recipe_id,
"description": "Updated via MCP",
"ingredients": ["100g flour", "2 eggs"],
"instructions": ["Mix ingredients", "Cook"],
"category": "Updated",
},
)
assert update_result.isError is False, (
f"MCP recipe update failed: {update_result.content}"
)
# 4. Verify update via direct NextcloudClient
await asyncio.sleep(1) # Allow propagation
updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
assert updated_recipe["description"] == "Updated via MCP"
assert len(updated_recipe["recipeIngredient"]) == 2
assert len(updated_recipe["recipeInstructions"]) == 2
assert updated_recipe["recipeCategory"] == "Updated"
logger.info(f"Successfully updated recipe {created_recipe_id} via MCP")
finally:
# Cleanup
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup recipe: {e}")
async def test_mcp_cookbook_delete_recipe(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test deleting a recipe via MCP tools."""
unique_suffix = uuid.uuid4().hex[:8]
recipe_name = f"MCP Delete Test {unique_suffix}"
recipe_data = {
"name": recipe_name,
"description": "Recipe to be deleted",
"recipeIngredient": ["test"],
"recipeInstructions": ["test"],
}
created_recipe_id = None
try:
# 1. Create recipe via direct client
logger.info(f"Creating recipe for delete test: {recipe_name}")
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
# 2. Delete recipe via MCP
logger.info(f"Deleting recipe via MCP: {created_recipe_id}")
delete_result = await nc_mcp_client.call_tool(
"nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id}
)
assert delete_result.isError is False, (
f"MCP recipe deletion failed: {delete_result.content}"
)
# 3. Verify deletion via direct NextcloudClient
try:
await nc_client.cookbook.get_recipe(created_recipe_id)
pytest.fail("Recipe should have been deleted but was still found")
except Exception:
# Expected - recipe should be deleted
logger.info(f"Successfully verified recipe {created_recipe_id} was deleted")
created_recipe_id = None # Mark as cleaned up
finally:
# Cleanup in case of test failure
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup recipe: {e}")
async def test_mcp_cookbook_import_recipe_from_url(
nc_mcp_client: ClientSession,
nc_client: NextcloudClient,
):
"""Test importing a recipe from a URL via MCP tools.
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"
created_recipe_id = None
try:
# 1. Import recipe via MCP
logger.info(f"Importing recipe from nginx container via MCP: {test_url}")
import_result = await nc_mcp_client.call_tool(
"nc_cookbook_import_recipe", {"url": test_url}
)
assert import_result.isError is False, (
f"MCP recipe import failed: {import_result.content}"
)
import_response = json.loads(import_result.content[0].text)
created_recipe_id = int(import_response["recipe_id"])
imported_recipe = import_response["recipe"]
logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}")
# 2. Verify basic recipe structure
assert imported_recipe["name"] == "Black Pepper Tofu"
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()
# 3. Verify we can read it back via direct NextcloudClient
retrieved = await nc_client.cookbook.get_recipe(created_recipe_id)
assert retrieved["name"] == imported_recipe["name"]
logger.info(f"Verified imported recipe ID: {created_recipe_id}")
finally:
# Cleanup
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up imported recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup imported recipe: {e}")
async def test_mcp_cookbook_search_recipes(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test searching recipes via MCP tools."""
unique_keyword = f"mcptestkeyword{uuid.uuid4().hex[:8]}"
recipe_name = f"MCP Search Test {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"description": f"Recipe for testing MCP search with {unique_keyword}",
"keywords": unique_keyword,
"recipeIngredient": ["test ingredient"],
"recipeInstructions": ["test instruction"],
}
created_recipe_id = None
try:
# 1. Create recipe via direct client
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
# 2. Allow time for indexing
await asyncio.sleep(2)
# 3. Search for the recipe via MCP
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
search_result = await nc_mcp_client.call_tool(
"nc_cookbook_search_recipes", {"query": unique_keyword}
)
assert search_result.isError is False, (
f"MCP recipe search failed: {search_result.content}"
)
search_response = json.loads(search_result.content[0].text)
search_results = search_response["recipes"]
assert isinstance(search_results, list)
assert len(search_results) > 0
# 4. Verify our recipe is in the results
found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results)
assert found, f"Recipe {created_recipe_id} not found in search results"
logger.info(
f"Successfully found recipe {created_recipe_id} in MCP search results"
)
finally:
# Cleanup
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup recipe: {e}")
async def test_mcp_cookbook_list_recipes(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test listing all recipes via MCP tools."""
logger.info("Listing all recipes via MCP")
list_result = await nc_mcp_client.call_tool("nc_cookbook_list_recipes", {})
assert list_result.isError is False, (
f"MCP list recipes failed: {list_result.content}"
)
list_response = json.loads(list_result.content[0].text)
recipes = list_response["recipes"]
assert isinstance(recipes, list)
logger.info(f"Found {len(recipes)} recipes via MCP")
async def test_mcp_cookbook_categories_workflow(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test category listing and filtering via MCP tools."""
unique_category = f"MCPTestCategory{uuid.uuid4().hex[:8]}"
recipe_name = f"MCP Category Test {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"recipeCategory": unique_category,
"recipeIngredient": ["test"],
"recipeInstructions": ["test"],
}
created_recipe_id = None
try:
# 1. Create recipe in test category
logger.info(f"Creating recipe in category: {unique_category}")
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
# 2. Allow time for indexing
await asyncio.sleep(2)
# 3. List categories via MCP
logger.info("Listing categories via MCP")
categories_result = await nc_mcp_client.call_tool(
"nc_cookbook_list_categories", {}
)
assert categories_result.isError is False, (
f"MCP list categories failed: {categories_result.content}"
)
categories_response = json.loads(categories_result.content[0].text)
categories = categories_response["categories"]
assert isinstance(categories, list)
logger.info(f"Found {len(categories)} categories via MCP")
# 4. Get recipes in this category via MCP
logger.info(f"Getting recipes in category via MCP: {unique_category}")
category_recipes_result = await nc_mcp_client.call_tool(
"nc_cookbook_get_recipes_in_category", {"category": unique_category}
)
assert category_recipes_result.isError is False, (
f"MCP get recipes in category failed: {category_recipes_result.content}"
)
category_recipes_response = json.loads(category_recipes_result.content[0].text)
recipes_in_category = category_recipes_response["recipes"]
assert isinstance(recipes_in_category, list)
assert len(recipes_in_category) > 0
# 5. Verify our recipe is in the results
found = any(
str(r.get("id")) == str(created_recipe_id) for r in recipes_in_category
)
assert found, (
f"Recipe {created_recipe_id} not found in category {unique_category}"
)
logger.info(f"Successfully found recipe in category {unique_category} via MCP")
finally:
# Cleanup
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup recipe: {e}")
async def test_mcp_cookbook_keywords_workflow(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test keyword listing and filtering via MCP tools."""
unique_keyword = f"mcptesttag{uuid.uuid4().hex[:8]}"
recipe_name = f"MCP Keyword Test {uuid.uuid4().hex[:8]}"
recipe_data = {
"name": recipe_name,
"keywords": f"{unique_keyword},mcptesting",
"recipeIngredient": ["test"],
"recipeInstructions": ["test"],
}
created_recipe_id = None
try:
# 1. Create recipe with test keywords
logger.info(f"Creating recipe with keyword: {unique_keyword}")
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
# 2. Allow extra time for indexing and trigger reindex
await asyncio.sleep(3)
await nc_client.cookbook.reindex()
await asyncio.sleep(2)
# 3. List keywords via MCP
logger.info("Listing keywords via MCP")
keywords_result = await nc_mcp_client.call_tool("nc_cookbook_list_keywords", {})
assert keywords_result.isError is False, (
f"MCP list keywords failed: {keywords_result.content}"
)
keywords_response = json.loads(keywords_result.content[0].text)
keywords = keywords_response["keywords"]
assert isinstance(keywords, list)
logger.info(f"Found {len(keywords)} keywords via MCP")
# 4. Get recipes with this keyword via MCP
logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}")
keyword_recipes_result = await nc_mcp_client.call_tool(
"nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]}
)
assert keyword_recipes_result.isError is False, (
f"MCP get recipes with keywords failed: {keyword_recipes_result.content}"
)
keyword_recipes_response = json.loads(keyword_recipes_result.content[0].text)
recipes_with_keywords = keyword_recipes_response["recipes"]
assert isinstance(recipes_with_keywords, list)
# Keyword filtering might not find recipes immediately due to indexing
if len(recipes_with_keywords) > 0:
# Verify our recipe is in the results if any are found
found = any(
str(r.get("id")) == str(created_recipe_id)
for r in recipes_with_keywords
)
if found:
logger.info(
f"Successfully found recipe with keyword {unique_keyword} via MCP"
)
else:
logger.warning(
f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found"
)
else:
logger.warning(
f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay"
)
finally:
# Cleanup
if created_recipe_id is not None:
try:
await nc_client.cookbook.delete_recipe(created_recipe_id)
logger.info(f"Cleaned up recipe {created_recipe_id}")
except Exception as e:
logger.warning(f"Failed to cleanup recipe: {e}")
async def test_mcp_cookbook_config_and_version(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test getting Cookbook configuration and version via MCP resources."""
# 1. Get version via MCP resource
logger.info("Getting Cookbook version via MCP resource")
version_result = await nc_mcp_client.read_resource("cookbook://version")
assert len(version_result.contents) > 0
version_response = json.loads(version_result.contents[0].text)
assert "cookbook_version" in version_response
assert "api_version" in version_response
logger.info(f"Cookbook version from MCP: {version_response}")
# 2. Verify version via direct NextcloudClient
direct_version = await nc_client.cookbook.get_version()
assert direct_version["cookbook_version"] == version_response["cookbook_version"]
assert (
direct_version["api_version"]["epoch"]
== version_response["api_version"]["epoch"]
)
# 3. Get config via MCP resource
logger.info("Getting Cookbook config via MCP resource")
config_result = await nc_mcp_client.read_resource("cookbook://config")
assert len(config_result.contents) > 0
config_response = json.loads(config_result.contents[0].text)
assert isinstance(config_response, dict)
logger.info(f"Cookbook config from MCP: {config_response}")
# 4. Verify config via direct NextcloudClient
direct_config = await nc_client.cookbook.get_config()
# Both should be dicts - exact match may vary based on config
assert isinstance(config_response, dict)
assert isinstance(direct_config, dict)
logger.info("Successfully verified Cookbook version and config via MCP")
async def test_mcp_cookbook_reindex(
nc_mcp_client: ClientSession, nc_client: NextcloudClient
):
"""Test triggering a recipe reindex via MCP tools."""
logger.info("Triggering recipe reindex via MCP")
reindex_result = await nc_mcp_client.call_tool("nc_cookbook_reindex", {})
assert reindex_result.isError is False, (
f"MCP reindex failed: {reindex_result.content}"
)
reindex_response = json.loads(reindex_result.content[0].text)
assert isinstance(reindex_response["message"], str)
logger.info(f"Reindex result from MCP: {reindex_response['message']}")
+569
View File
@@ -0,0 +1,569 @@
import json
import logging
import uuid
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
# Stack MCP Tools Tests
async def test_deck_stack_mcp_tools(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
):
"""Test complete deck stack operations via MCP tools."""
board_id = temporary_board["id"]
stack_title = f"MCP Test Stack {uuid.uuid4().hex[:8]}"
stack_order = 1
# 1. Create stack via MCP tool
logger.info(f"Creating stack via MCP: {stack_title}")
create_result = await nc_mcp_client.call_tool(
"deck_create_stack",
{"board_id": board_id, "title": stack_title, "order": stack_order},
)
assert create_result.isError is False, (
f"MCP stack creation failed: {create_result.content}"
)
created_stack_response = json.loads(create_result.content[0].text)
stack_id = created_stack_response["id"]
assert created_stack_response["title"] == stack_title
assert created_stack_response["order"] == stack_order
logger.info(f"Stack created via MCP with ID: {stack_id}")
try:
# 2. Get stack via MCP resource
logger.info(f"Getting stack via MCP resource: {stack_id}")
get_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
)
assert len(get_result.contents) == 1, "Expected exactly one content item"
get_stack_response = json.loads(get_result.contents[0].text)
assert get_stack_response["title"] == stack_title
logger.info("Stack retrieved via MCP resource successfully")
# 3. Update stack via MCP tool
updated_title = f"Updated {stack_title}"
updated_order = 2
logger.info(f"Updating stack via MCP tool: {stack_id}")
update_result = await nc_mcp_client.call_tool(
"deck_update_stack",
{
"board_id": board_id,
"stack_id": stack_id,
"title": updated_title,
"order": updated_order,
},
)
assert update_result.isError is False, (
f"MCP stack update failed: {update_result.content}"
)
logger.info("Stack updated via MCP tool successfully")
# 4. Verify update via direct client
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("Stack update verified via direct client")
# 5. List stacks via MCP resource
logger.info("Listing stacks via MCP resource")
list_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks"
)
assert len(list_result.contents) == 1, "Expected exactly one content item"
stacks_data = json.loads(list_result.contents[0].text)
assert isinstance(stacks_data, list)
# Verify our stack is in the list
stack_ids = [stack["id"] for stack in stacks_data]
assert stack_id in stack_ids, "Updated stack not found in list"
logger.info(f"Stack {stack_id} found in stacks list")
# 6. Read stack via MCP resource
logger.info(f"Reading stack via MCP resource: {stack_id}")
read_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
)
read_stack_data = json.loads(read_result.contents[0].text)
assert read_stack_data["title"] == updated_title
logger.info("Stack read via MCP resource successfully")
finally:
# Clean up
await nc_client.deck.delete_stack(board_id, stack_id)
logger.info(f"Cleaned up stack ID: {stack_id}")
# Card MCP Tools Tests
async def test_deck_card_mcp_tools(
nc_mcp_client: ClientSession,
nc_client: NextcloudClient,
temporary_board_with_stack: tuple,
):
"""Test complete deck card operations via MCP tools."""
board_data, stack_data = temporary_board_with_stack
board_id = board_data["id"]
stack_id = stack_data["id"]
card_title = f"MCP Test Card {uuid.uuid4().hex[:8]}"
card_description = f"Test description for {card_title}"
# 1. Create card via MCP tool
logger.info(f"Creating card via MCP: {card_title}")
create_result = await nc_mcp_client.call_tool(
"deck_create_card",
{
"board_id": board_id,
"stack_id": stack_id,
"title": card_title,
"description": card_description,
"type": "plain",
"order": 1,
},
)
assert create_result.isError is False, (
f"MCP card creation failed: {create_result.content}"
)
created_card_response = json.loads(create_result.content[0].text)
card_id = created_card_response["id"]
assert created_card_response["title"] == card_title
assert created_card_response["description"] == card_description
logger.info(f"Card created via MCP with ID: {card_id}")
try:
# 2. Get card via MCP resource
logger.info(f"Getting card via MCP resource: {card_id}")
get_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
)
assert len(get_result.contents) == 1, "Expected exactly one content item"
get_card_response = json.loads(get_result.contents[0].text)
assert get_card_response["title"] == card_title
logger.info("Card retrieved via MCP resource successfully")
# 3. Update card via MCP tool
updated_title = f"Updated {card_title}"
updated_description = f"Updated description for {card_title}"
logger.info(f"Updating card via MCP tool: {card_id}")
update_result = await nc_mcp_client.call_tool(
"deck_update_card",
{
"board_id": board_id,
"stack_id": stack_id,
"card_id": card_id,
"title": updated_title,
"description": updated_description,
},
)
assert update_result.isError is False, (
f"MCP card update failed: {update_result.content}"
)
logger.info("Card updated via MCP tool successfully")
# 4. Verify update via direct client
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("Card update verified via direct client")
# 5. Archive/unarchive card via MCP tools
logger.info(f"Archiving card via MCP tool: {card_id}")
archive_result = await nc_mcp_client.call_tool(
"deck_archive_card",
{"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
)
assert archive_result.isError is False, (
f"MCP card archive failed: {archive_result.content}"
)
logger.info("Card archived via MCP tool successfully")
logger.info(f"Unarchiving card via MCP tool: {card_id}")
unarchive_result = await nc_mcp_client.call_tool(
"deck_unarchive_card",
{"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
)
assert unarchive_result.isError is False, (
f"MCP card unarchive failed: {unarchive_result.content}"
)
logger.info("Card unarchived via MCP tool successfully")
# 6. Move card to different position via MCP tool
logger.info(f"Reordering card via MCP tool: {card_id}")
reorder_result = await nc_mcp_client.call_tool(
"deck_reorder_card",
{
"board_id": board_id,
"stack_id": stack_id,
"card_id": card_id,
"order": 10,
"target_stack_id": stack_id,
},
)
assert reorder_result.isError is False, (
f"MCP card reorder failed: {reorder_result.content}"
)
logger.info("Card reordered via MCP tool successfully")
# 7. Read card via MCP resource
logger.info(f"Reading card via MCP resource: {card_id}")
read_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
)
read_card_data = json.loads(read_result.contents[0].text)
assert read_card_data["title"] == updated_title
logger.info("Card read via MCP resource successfully")
finally:
# Clean up
await nc_client.deck.delete_card(board_id, stack_id, card_id)
logger.info(f"Cleaned up card ID: {card_id}")
# Label MCP Tools Tests
async def test_deck_label_mcp_tools(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
):
"""Test complete deck label operations via MCP tools."""
board_id = temporary_board["id"]
label_title = f"MCP Test Label {uuid.uuid4().hex[:8]}"
label_color = "FF0000" # Red
# 1. Create label via MCP tool
logger.info(f"Creating label via MCP: {label_title}")
create_result = await nc_mcp_client.call_tool(
"deck_create_label",
{"board_id": board_id, "title": label_title, "color": label_color},
)
assert create_result.isError is False, (
f"MCP label creation failed: {create_result.content}"
)
created_label_response = json.loads(create_result.content[0].text)
label_id = created_label_response["id"]
assert created_label_response["title"] == label_title
assert created_label_response["color"] == label_color
logger.info(f"Label created via MCP with ID: {label_id}")
try:
# 2. Get label via MCP resource
logger.info(f"Getting label via MCP resource: {label_id}")
get_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/labels/{label_id}"
)
assert len(get_result.contents) == 1, "Expected exactly one content item"
get_label_response = json.loads(get_result.contents[0].text)
assert get_label_response["title"] == label_title
logger.info("Label retrieved via MCP resource successfully")
# 3. Update label via MCP tool
updated_title = f"Updated {label_title}"
updated_color = "00FF00" # Green
logger.info(f"Updating label via MCP tool: {label_id}")
update_result = await nc_mcp_client.call_tool(
"deck_update_label",
{
"board_id": board_id,
"label_id": label_id,
"title": updated_title,
"color": updated_color,
},
)
assert update_result.isError is False, (
f"MCP label update failed: {update_result.content}"
)
logger.info("Label updated via MCP tool successfully")
# 4. Verify update via direct client
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("Label update verified via direct client")
# 5. Read label via MCP resource
logger.info(f"Reading label via MCP resource: {label_id}")
read_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/labels/{label_id}"
)
read_label_data = json.loads(read_result.contents[0].text)
assert read_label_data["title"] == updated_title
logger.info("Label read via MCP resource successfully")
finally:
# Clean up
await nc_client.deck.delete_label(board_id, label_id)
logger.info(f"Cleaned up label ID: {label_id}")
# Label-Card Assignment Tests
async def test_deck_card_label_assignment_mcp_tools(
nc_mcp_client: ClientSession,
nc_client: NextcloudClient,
temporary_board_with_card: tuple,
):
"""Test card-label assignment operations via MCP tools."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
stack_id = stack_data["id"]
card_id = card_data["id"]
# Create a label for assignment
label = await nc_client.deck.create_label(
board_id, "Assignment Test Label", "0000FF"
)
label_id = label.id
try:
# 1. Assign label to card via MCP tool
logger.info(f"Assigning label {label_id} to card {card_id} via MCP")
assign_result = await nc_mcp_client.call_tool(
"deck_assign_label_to_card",
{
"board_id": board_id,
"stack_id": stack_id,
"card_id": card_id,
"label_id": label_id,
},
)
assert assign_result.isError is False, (
f"MCP label assignment failed: {assign_result.content}"
)
logger.info("Label assigned to card via MCP tool successfully")
# 2. Verify assignment via direct client
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
if card.labels:
label_ids = [label.id for label in card.labels]
assert label_id in label_ids, "Label not found in card labels"
logger.info("Label assignment verified via direct client")
# 3. Remove label from card via MCP tool
logger.info(f"Removing label {label_id} from card {card_id} via MCP")
remove_result = await nc_mcp_client.call_tool(
"deck_remove_label_from_card",
{
"board_id": board_id,
"stack_id": stack_id,
"card_id": card_id,
"label_id": label_id,
},
)
assert remove_result.isError is False, (
f"MCP label removal failed: {remove_result.content}"
)
logger.info("Label removed from card via MCP tool successfully")
# 4. Verify removal via direct client
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
if card.labels:
label_ids = [label.id for label in card.labels]
assert label_id not in label_ids, (
"Label still found in card labels after removal"
)
logger.info("Label removal verified via direct client")
finally:
# Clean up
await nc_client.deck.delete_label(board_id, label_id)
logger.info(f"Cleaned up label ID: {label_id}")
# User Assignment Tests
async def test_deck_card_user_assignment_mcp_tools(
nc_mcp_client: ClientSession,
nc_client: NextcloudClient,
temporary_board_with_card: tuple,
):
"""Test card-user assignment operations via MCP tools."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
stack_id = stack_data["id"]
card_id = card_data["id"]
# Use the current user ID (admin in most test environments)
user_id = "admin"
# 1. Assign user to card via MCP tool
logger.info(f"Assigning user {user_id} to card {card_id} via MCP")
assign_result = await nc_mcp_client.call_tool(
"deck_assign_user_to_card",
{
"board_id": board_id,
"stack_id": stack_id,
"card_id": card_id,
"user_id": user_id,
},
)
assert assign_result.isError is False, (
f"MCP user assignment failed: {assign_result.content}"
)
logger.info("User assigned to card via MCP tool successfully")
# 2. Verify assignment via direct client
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
if card.assignedUsers:
user_ids = []
for user in card.assignedUsers:
if hasattr(user, "participant"):
# It's a DeckAssignedUser with participant
user_ids.append(user.participant.uid)
elif hasattr(user, "uid"):
# It's a direct DeckUser
user_ids.append(user.uid)
assert user_id in user_ids, "User not found in card assigned users"
logger.info("User assignment verified via direct client")
# 3. Unassign user from card via MCP tool
logger.info(f"Unassigning user {user_id} from card {card_id} via MCP")
unassign_result = await nc_mcp_client.call_tool(
"deck_unassign_user_from_card",
{
"board_id": board_id,
"stack_id": stack_id,
"card_id": card_id,
"user_id": user_id,
},
)
assert unassign_result.isError is False, (
f"MCP user unassignment failed: {unassign_result.content}"
)
logger.info("User unassigned from card via MCP tool successfully")
# 4. Verify unassignment via direct client
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
if card.assignedUsers:
user_ids = []
for user in card.assignedUsers:
if hasattr(user, "participant"):
# It's a DeckAssignedUser with participant
user_ids.append(user.participant.uid)
elif hasattr(user, "uid"):
# It's a direct DeckUser
user_ids.append(user.uid)
assert user_id not in user_ids, (
"User still found in card assigned users after removal"
)
logger.info("User unassignment verified via direct client")
# Error handling tests
async def test_deck_mcp_tools_error_handling(nc_mcp_client: ClientSession):
"""Test error handling for deck MCP tools with invalid parameters."""
non_existent_id = 999999999
# Test stack operations with non-existent board
stack_result = await nc_mcp_client.call_tool(
"deck_create_stack",
{"board_id": non_existent_id, "title": "Should Fail", "order": 1},
)
assert stack_result.isError is True, (
"Expected error for stack creation on non-existent board"
)
# Test card operations with non-existent IDs
card_result = await nc_mcp_client.call_tool(
"deck_create_card",
{
"board_id": non_existent_id,
"stack_id": non_existent_id,
"title": "Should Fail",
"type": "plain",
},
)
assert card_result.isError is True, (
"Expected error for card creation with non-existent IDs"
)
# Test label operations with non-existent board
label_result = await nc_mcp_client.call_tool(
"deck_create_label",
{"board_id": non_existent_id, "title": "Should Fail", "color": "FF0000"},
)
assert label_result.isError is True, (
"Expected error for label creation on non-existent board"
)
logger.info("Error handling tests passed for deck MCP tools")
# Resource template tests
async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession):
"""Test deck MCP resource templates are properly registered."""
templates = await nc_mcp_client.list_resource_templates()
template_uris = [template.uriTemplate for template in templates.resourceTemplates]
expected_templates = [
"nc://Deck/boards/{board_id}/stacks/{stack_id}",
"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
"nc://Deck/boards/{board_id}/labels/{label_id}",
]
for expected_template in expected_templates:
assert expected_template in template_uris, (
f"Expected template '{expected_template}' not found"
)
logger.info(f"Found expected deck resource template: {expected_template}")
# Listing resource tests
async def test_deck_mcp_listing_resources(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""Test deck MCP listing resources for stacks and cards."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
stack_id = stack_data["id"]
# 1. Test listing stacks resource
logger.info(f"Reading stacks list via MCP resource for board {board_id}")
stacks_resource_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks"
)
stacks_resource_data = json.loads(stacks_resource_result.contents[0].text)
assert isinstance(stacks_resource_data, list)
# Verify our stack is in the resource list
stack_ids = [stack["id"] for stack in stacks_resource_data]
assert stack_id in stack_ids, "Stack not found in stacks resource list"
logger.info("Stack found in stacks resource list")
# 2. Test listing cards resource
logger.info(f"Reading cards list via MCP resource for stack {stack_id}")
cards_resource_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards"
)
cards_resource_data = json.loads(cards_resource_result.contents[0].text)
assert isinstance(cards_resource_data, list)
# Verify our card is in the resource list
card_ids = [card["id"] for card in cards_resource_data]
assert card_data["id"] in card_ids, "Card not found in cards resource list"
logger.info("Card found in cards resource list")
# 3. Test listing labels resource
logger.info(f"Reading labels list via MCP resource for board {board_id}")
labels_resource_result = await nc_mcp_client.read_resource(
f"nc://Deck/boards/{board_id}/labels"
)
labels_resource_data = json.loads(labels_resource_result.contents[0].text)
assert isinstance(labels_resource_data, list)
logger.info("Labels resource read successfully")
@@ -0,0 +1,479 @@
"""
Integration tests for token introspection authorization.
These tests verify that the introspection endpoint properly enforces
authorization rules:
1. Client authentication is required (401 if missing)
2. Only the token owner can introspect its own tokens
3. Only the designated resource server can introspect tokens
4. Other clients cannot introspect tokens they don't own or aren't the audience for
"""
import asyncio
import logging
import os
import secrets
# Import helpers from conftest
import time
from typing import AsyncGenerator
from urllib.parse import quote
import httpx
import pytest
# Import from the root tests/ conftest.py using relative import
from ..conftest import _handle_oauth_consent_screen
logger = logging.getLogger(__name__)
@pytest.fixture(scope="module")
def nextcloud_host() -> str:
"""Get Nextcloud host from environment."""
host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
return host
@pytest.fixture(scope="module")
async def oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
"""Discover OIDC endpoints."""
async with httpx.AsyncClient(timeout=30.0) as client:
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
response = await client.get(discovery_url)
response.raise_for_status()
config = response.json()
return {
"token_endpoint": config["token_endpoint"],
"authorization_endpoint": config.get("authorization_endpoint"),
"introspection_endpoint": config.get("introspection_endpoint"),
"registration_endpoint": config.get("registration_endpoint"),
}
@pytest.fixture(scope="module")
async def test_oauth_clients(
nextcloud_host: str, oidc_endpoints: dict[str, str], oauth_callback_server
) -> AsyncGenerator[dict[str, tuple[str, str]], None]:
"""
Create multiple OAuth clients for introspection testing.
Returns a dict mapping client names to (client_id, client_secret) tuples.
"""
from nextcloud_mcp_server.auth.client_registration import register_client
clients = {}
registration_endpoint = oidc_endpoints["registration_endpoint"]
# Get the correct callback URL from the oauth_callback_server fixture
auth_states, callback_url = oauth_callback_server
# Create client A (will be the token owner)
logger.info("Creating OAuth client A for introspection testing")
client_a = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="Introspection Test Client A",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type="Bearer", # Use opaque tokens for this test
)
clients["clientA"] = (client_a.client_id, client_a.client_secret)
logger.info(f"Created client A: {client_a.client_id[:16]}...")
# Create client B (will attempt to introspect client A's tokens)
logger.info("Creating OAuth client B for introspection testing")
client_b = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="Introspection Test Client B",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type="Bearer",
)
clients["clientB"] = (client_b.client_id, client_b.client_secret)
logger.info(f"Created client B: {client_b.client_id[:16]}...")
# Create client C (third party, should not be able to introspect)
logger.info("Creating OAuth client C for introspection testing")
client_c = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="Introspection Test Client C",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type="Bearer",
)
clients["clientC"] = (client_c.client_id, client_c.client_secret)
logger.info(f"Created client C: {client_c.client_id[:16]}...")
yield clients
# Cleanup is handled by Nextcloud - clients will be removed when tests are done
logger.info("Test OAuth clients fixture complete")
@pytest.mark.integration
async def test_introspection_requires_client_authentication(
oidc_endpoints: dict[str, str],
):
"""
Test that the introspection endpoint requires client authentication.
Expected: 401 UNAUTHORIZED when credentials are missing or invalid.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: No credentials
response = await client.post(
introspection_endpoint,
data={"token": "some_token"},
)
assert response.status_code == 401, "Should return 401 without credentials"
data = response.json()
assert data.get("error") == "invalid_client"
# Test 2: Invalid credentials
response = await client.post(
introspection_endpoint,
data={"token": "some_token"},
auth=("invalid_client", "invalid_secret"),
)
assert response.status_code == 401, "Should return 401 with invalid credentials"
data = response.json()
logger.info(f"Invalid client response: {data}")
# Response may be either {"error": "invalid_client"} or {"message": "..."}
# Both are acceptable as long as we get 401
assert "error" in data or "message" in data, "Should return error information"
async def _obtain_token_for_client(
browser,
oauth_callback_server,
client_id: str,
client_secret: str,
token_endpoint: str,
authorization_endpoint: str,
scope: str = "openid profile email",
resource: str | None = None,
) -> str:
"""
Helper to obtain an OAuth token using existing callback server and playwright automation.
Reuses the pattern from conftest.py's playwright_oauth_token fixture.
"""
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
# Get callback server from fixture
auth_states, callback_url = oauth_callback_server
# Generate unique state parameter
state = secrets.token_urlsafe(32)
# Construct authorization URL
auth_url_parts = [
f"{authorization_endpoint}?",
"response_type=code&",
f"client_id={client_id}&",
f"redirect_uri={quote(callback_url, safe='')}&",
f"state={state}&",
f"scope={quote(scope, safe='')}",
]
if resource:
auth_url_parts.append(f"&resource={quote(resource, safe='')}")
auth_url = "".join(auth_url_parts)
logger.info(f"Obtaining token for client {client_id[:16]}... with scopes={scope}")
if resource:
logger.info(f" Resource parameter: {resource[:16]}...")
# Browser automation (same pattern as conftest.py)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
logger.debug(f"Navigating to: {auth_url[:100]}...")
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
current_url = page.url
logger.debug(f"Current URL after navigation: {current_url}")
# Handle login if needed
if "/login" in current_url or "/index.php/login" in current_url:
logger.info("Login page detected, filling credentials...")
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=60000)
current_url = page.url
logger.info(f"After login: {current_url}")
# Wait a bit for page to fully render after login
await asyncio.sleep(2)
current_url = page.url
logger.info(f"After waiting, current URL: {current_url}")
# Check page content for debugging
page_content = await page.content()
has_consent_div = "#oidc-consent" in page_content
logger.info(f"Page has #oidc-consent div: {has_consent_div}")
# Handle consent screen using the helper from conftest
try:
consent_handled = await _handle_oauth_consent_screen(page, username)
logger.info(f"Consent screen handled: {consent_handled}")
except Exception as e:
logger.warning(f"Error handling consent screen: {e}")
# Take screenshot for debugging
await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png")
logger.error("Consent error screenshot saved")
raise
# Wait for callback server to receive auth code
logger.info("Waiting for callback server to receive auth code...")
timeout_seconds = 30
start_time = time.time()
while state not in auth_states:
if time.time() - start_time > timeout_seconds:
screenshot_path = (
f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png"
)
await page.screenshot(path=screenshot_path)
logger.error(f"Timeout! Screenshot saved to {screenshot_path}")
logger.error(f"Current URL: {page.url}")
raise TimeoutError(
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
)
await asyncio.sleep(0.5)
auth_code = auth_states[state]
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
finally:
await context.close()
# Exchange code for token
logger.debug("Exchanging authorization code for access token...")
async with httpx.AsyncClient(timeout=30.0) as http_client:
token_response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access_token in response: {token_data}")
logger.info("Successfully obtained access token")
return access_token
@pytest.mark.integration
async def test_client_cannot_introspect_other_clients_tokens(
playwright_oauth_token: str,
shared_oauth_client_credentials: tuple,
test_oauth_clients: dict[str, tuple[str, str]],
oidc_endpoints: dict[str, str],
):
"""
Test that one client cannot introspect tokens owned by another client.
This test uses a pre-authorized shared OAuth client (with existing token)
and verifies that a different client cannot introspect that token.
Expected: introspection returns {active: false} to not reveal token existence.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
# Use the shared OAuth client's token (pre-authorized, working)
access_token = playwright_oauth_token
shared_client_id, shared_client_secret, _, _, _ = shared_oauth_client_credentials
# Get a different client to try to introspect
different_client_id, different_client_secret = test_oauth_clients["clientB"]
logger.info(
f"Testing introspection with shared client token: {access_token[:16]}..."
)
logger.info(f"Shared client ID: {shared_client_id[:16]}...")
logger.info(f"Different client ID: {different_client_id[:16]}...")
async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: The owning client (shared client) can introspect its own token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(shared_client_id, shared_client_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Owner client introspection response: {data}")
assert data.get("active") is True, (
"Owner client should be able to introspect its own token"
)
# Test 2: A different client CANNOT introspect the shared client's token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(different_client_id, different_client_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Different client introspection response: {data}")
assert data.get("active") is False, (
"Different client should NOT be able to introspect another client's token"
)
@pytest.mark.integration
async def test_introspection_with_resource_parameter(
browser,
oauth_callback_server,
test_oauth_clients: dict[str, tuple[str, str]],
oidc_endpoints: dict[str, str],
nextcloud_host: str,
):
"""
Test that the resource server (specified via 'resource' parameter) can introspect tokens.
This test verifies that when a token is issued with resource=clientB,
clientB can introspect it even though it's owned by clientA.
This requires obtaining a token with the 'resource' parameter set via authorization code grant.
Uses playwright automation to obtain real tokens.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
client_a_id, client_a_secret = test_oauth_clients["clientA"]
client_b_id, client_b_secret = test_oauth_clients["clientB"]
client_c_id, client_c_secret = test_oauth_clients["clientC"]
token_endpoint = oidc_endpoints["token_endpoint"]
authorization_endpoint = oidc_endpoints.get("authorization_endpoint")
if not authorization_endpoint:
pytest.skip("Authorization endpoint not available")
# Obtain a token for client A with resource parameter set to client B
try:
access_token = await _obtain_token_for_client(
browser=browser,
oauth_callback_server=oauth_callback_server,
client_id=client_a_id,
client_secret=client_a_secret,
token_endpoint=token_endpoint,
authorization_endpoint=authorization_endpoint,
scope="openid profile email",
resource=client_b_id, # Set client B as the resource server
)
except Exception as e:
logger.error(f"Failed to obtain token with resource parameter: {e}")
pytest.skip(f"Cannot obtain test token with resource parameter: {e}")
logger.info(
f"Obtained access token from client A with resource={client_b_id}: {access_token[:16]}..."
)
# Test introspection
async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: Client A (owner) can introspect its own token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(client_a_id, client_a_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Client A (owner) introspection response: {data}")
assert data.get("active") is True, (
"Client A (owner) should be able to introspect its own token"
)
# Test 2: Client B (resource server) can introspect the token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(client_b_id, client_b_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Client B (resource server) introspection response: {data}")
assert data.get("active") is True, (
"Client B (resource server) should be able to introspect token intended for it"
)
# Verify the resource field in the response matches client B
logger.info(f"Full introspection response from Client B: {data}")
# Test 3: Client C CANNOT introspect the token (not owner, not resource server)
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(client_c_id, client_c_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Client C (third party) introspection response: {data}")
assert data.get("active") is False, (
"Client C should NOT be able to introspect token (not owner or resource server)"
)
@pytest.mark.integration
async def test_introspection_returns_inactive_for_invalid_token(
test_oauth_clients: dict[str, tuple[str, str]],
oidc_endpoints: dict[str, str],
):
"""
Test that introspection returns {active: false} for invalid/unknown tokens.
This is important for security - we shouldn't reveal whether a token exists or not.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
client_a_id, client_a_secret = test_oauth_clients["clientA"]
async with httpx.AsyncClient(timeout=10.0) as client:
# Test with a fake token
response = await client.post(
introspection_endpoint,
data={"token": "completely_fake_token_12345"},
auth=(client_a_id, client_a_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Introspection response for fake token: {data}")
assert data.get("active") is False, (
"Should return active=false for invalid token"
)
# Should NOT return any other information
assert len(data) == 1, "Should only return 'active' field for invalid token"
if __name__ == "__main__":
# Run with: uv run pytest tests/server/test_introspection_authorization.py -v -s
pytest.main([__file__, "-v", "-s", "-m", "integration"])
+208
View File
@@ -0,0 +1,208 @@
"""
Test JWT token structure and scope support.
This test obtains a JWT token via OAuth and examines its structure.
"""
import base64
import json
import pytest
def decode_jwt_without_verification(token: str) -> dict:
"""
Decode JWT token without signature verification (for inspection only).
Returns:
Dict with header and payload
"""
parts = token.split(".")
if len(parts) != 3:
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
# Decode header
header = json.loads(
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
)
# Decode payload
payload = json.loads(
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
)
return {
"header": header,
"payload": payload,
}
@pytest.mark.integration
async def test_jwt_token_structure_with_custom_client():
"""
Test that we can create a JWT-enabled OAuth client and examine the token structure.
This test manually configures a JWT client and obtains a token.
"""
import os
import httpx
# This test requires manual setup of a JWT client
# Skip if not configured
client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID")
if not client_id:
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Fetch discovery
async with httpx.AsyncClient() as client:
discovery_response = await client.get(
f"{nextcloud_host}/.well-known/openid-configuration"
)
discovery_response.raise_for_status()
discovery = discovery_response.json()
_token_endpoint = discovery["token_endpoint"]
# For this test, we'll use client credentials grant if supported
# Otherwise, skip this test
pytest.skip(
"JWT token test requires OAuth flow - use manual testing script instead"
)
@pytest.mark.integration
async def test_opaque_token_vs_jwt_comparison():
"""
Compare opaque tokens vs JWT tokens to understand the differences.
This is a documentation test that explains the findings.
"""
# This test documents our findings about JWT vs opaque tokens
# Based on manual testing with the test script
findings = {
"oidc_app_capabilities": {
"supports_jwt_tokens": True,
"supports_opaque_tokens": True,
"configuration_method": "per-client via token_type field",
"jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)",
},
"dynamic_registration": {
"sets_allowed_scopes": False,
"note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request",
"workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API",
},
"jwt_token_structure": {
"header": {
"typ": "at+JWT", # RFC 9068 access token type
"alg": "RS256", # Signature algorithm
},
"payload_claims": {
"iss": "issuer URL",
"sub": "user ID",
"aud": "client ID",
"exp": "expiration timestamp",
"iat": "issued at timestamp",
"scope": "space-separated scope string (THIS IS THE KEY!)",
"client_id": "client identifier",
"jti": "JWT ID",
# Optional based on scopes:
"roles": "if roles scope present",
"groups": "if groups scope present",
"email": "if email scope present",
"name": "if profile scope present",
},
"scope_claim": {
"format": "space-separated string",
"example": "openid profile email nc:read nc:write",
"extraction": "payload['scope'].split()",
},
},
"scope_validation": {
"oidc_app": {
"validates": True,
"method": "Intersects requested scopes with allowed_scopes per client",
"location": "LoginRedirectorController.php:251-267",
},
"user_oidc_app": {
"validates_scopes": False,
"validates": ["token expiration", "issuer", "audience (optional)"],
"limitation": "Does NOT extract or validate scopes from JWT",
},
},
"token_size": {
"opaque": "72 characters",
"jwt": "~800-1200 characters (depends on claims)",
"overhead": "JWT is 10-15x larger than opaque tokens",
},
"recommendation": {
"for_mcp_server": "Use JWT tokens with self-validation",
"reasoning": [
"Can extract scopes directly from token payload",
"No additional API call needed",
"Standard approach (RFC 9068)",
"Works with existing oidc app",
],
"alternative": "Implement introspection endpoint in oidc app (future work)",
},
}
# Print findings for documentation
print("\n" + "=" * 80)
print("JWT Token vs Opaque Token Findings")
print("=" * 80)
print(json.dumps(findings, indent=2))
print("=" * 80 + "\n")
# This test always passes - it's for documentation
assert True, "Findings documented"
@pytest.mark.integration
async def test_scope_presence_in_jwt():
"""
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
NOTE: This test documents the expected behavior based on manual testing.
Actual implementation will be tested in integration tests after JWT validation is implemented.
"""
expected_behavior = {
"client_configuration": {
"allowed_scopes": "openid profile email nc:read nc:write",
"token_type": "jwt",
},
"authorization_request": {
"scope": "openid profile email nc:read nc:write",
},
"token_response": {
"access_token": "JWT with scope claim",
},
"jwt_payload": {
"scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes
},
"scope_filtering": {
"description": "oidc app filters requested scopes against allowed_scopes",
"example": {
"requested": "openid profile nc:read nc:write nc:admin",
"allowed": "openid profile email nc:read nc:write",
"granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested
},
},
}
print("\n" + "=" * 80)
print("Expected JWT Scope Behavior")
print("=" * 80)
print(json.dumps(expected_behavior, indent=2))
print("=" * 80 + "\n")
assert True, "Expected behavior documented"
if __name__ == "__main__":
# Run with: uv run pytest tests/server/test_jwt_tokens.py -v
pytest.main([__file__, "-v", "-s"])
+31 -1
View File
@@ -40,6 +40,12 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
"nc_webdav_write_file",
"nc_webdav_create_directory",
"nc_webdav_delete_resource",
"nc_webdav_move_resource",
"nc_webdav_copy_resource",
"nc_webdav_search_files",
"nc_webdav_find_by_name",
"nc_webdav_find_by_type",
"nc_webdav_list_favorites",
"nc_calendar_list_calendars",
"nc_calendar_create_event",
"nc_calendar_list_events",
@@ -51,7 +57,25 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
"nc_calendar_find_availability",
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"nc_calendar_list_todos",
"nc_calendar_create_todo",
"nc_calendar_update_todo",
"nc_calendar_delete_todo",
"nc_calendar_search_todos",
"deck_create_board",
"nc_cookbook_import_recipe",
"nc_cookbook_list_recipes",
"nc_cookbook_get_recipe",
"nc_cookbook_create_recipe",
"nc_cookbook_update_recipe",
"nc_cookbook_delete_recipe",
"nc_cookbook_search_recipes",
"nc_cookbook_list_categories",
"nc_cookbook_get_recipes_in_category",
"nc_cookbook_list_keywords",
"nc_cookbook_get_recipes_with_keywords",
"nc_cookbook_set_config",
"nc_cookbook_reindex",
]
for expected_tool in expected_tools:
@@ -85,7 +109,13 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
resource_uris.append(str(resource.uri)) # Convert to string for comparison
# Verify expected resources
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
expected_resources = [
"nc://capabilities",
"notes://settings",
"nc://Deck/boards",
"cookbook://version",
"cookbook://config",
]
for expected_resource in expected_resources:
assert expected_resource in resource_uris, (
+3 -2
View File
@@ -1,5 +1,6 @@
import json
import logging
import pytest
logger = logging.getLogger(__name__)
@@ -38,11 +39,11 @@ async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
)
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright):
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
"""Test that MCP OAuth client via Playwright can execute tools."""
# Test: Execute the 'nc_notes_search_notes' tool
result = await nc_mcp_oauth_client_playwright.call_tool(
result = await nc_mcp_oauth_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
+246
View File
@@ -0,0 +1,246 @@
"""Integration tests for JWT OAuth authentication.
These tests verify:
1. JWT token authentication works correctly
2. JWT token verification via JWKS
3. Scope information is properly extracted from JWT claims
4. Dynamic tool filtering works with JWT tokens
5. All MCP operations work with JWT authentication
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_jwt_mcp_server_connection(nc_mcp_oauth_jwt_client):
"""Test connection to JWT OAuth-enabled MCP server."""
result = await nc_mcp_oauth_jwt_client.list_tools()
assert result is not None
assert len(result.tools) > 0
logger.info(f"JWT OAuth MCP server has {len(result.tools)} tools available")
async def test_jwt_token_authentication(nc_mcp_oauth_jwt_client):
"""Test that JWT token authentication works."""
# Execute a simple read operation
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info(
f"Successfully authenticated with JWT token and executed tool, got {len(response_data['results'])} notes."
)
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
"""Test that list_tools works with JWT authentication."""
result = await nc_mcp_oauth_jwt_client.list_tools()
# Verify we have tools
assert len(result.tools) > 0
# Verify some expected tools exist
tool_names = [tool.name for tool in result.tools]
assert "nc_notes_get_note" in tool_names
assert "nc_notes_create_note" in tool_names
assert "nc_calendar_list_calendars" in tool_names
assert "nc_webdav_list_directory" in tool_names
logger.info(f"JWT server provides {len(result.tools)} tools")
async def test_jwt_read_operation(nc_mcp_oauth_jwt_client):
"""Test read operation with JWT authentication."""
# List calendars (read operation)
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
assert "calendars" in response_data
assert isinstance(response_data["calendars"], list)
logger.info(
f"Successfully executed read operation with JWT, got {len(response_data['calendars'])} calendars."
)
async def test_jwt_write_operation(nc_mcp_oauth_jwt_client):
"""Test write operation with JWT authentication."""
import uuid
# Create a note (write operation)
note_title = f"JWT Test Note {uuid.uuid4().hex[:8]}"
note_content = "This note was created during JWT authentication testing"
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_create_note",
arguments={
"title": note_title,
"content": note_content,
"category": "Testing",
},
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
# Verify note was created
assert "id" in response_data
assert response_data["title"] == note_title
note_id = response_data["id"]
logger.info(f"Successfully created note {note_id} with JWT authentication")
# Clean up: Delete the note
delete_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
assert delete_result.isError is False, f"Cleanup failed: {delete_result.content}"
logger.info(f"Cleaned up test note {note_id}")
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
"""Test multiple operations with same JWT token to verify token persistence."""
# First operation: Search notes
result1 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result1.isError is False
# Second operation: List calendars
result2 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert result2.isError is False
# Third operation: List directory
result3 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert result3.isError is False
logger.info("Successfully executed multiple operations with JWT token")
async def test_jwt_vs_opaque_token_compatibility(
nc_mcp_oauth_client, nc_mcp_oauth_jwt_client
):
"""Verify that both opaque and JWT tokens provide same functionality."""
# Execute same operation on both servers
opaque_result = await nc_mcp_oauth_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
jwt_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
# Both should succeed
assert opaque_result.isError is False
assert jwt_result.isError is False
# Both should have results
opaque_data = json.loads(opaque_result.content[0].text)
jwt_data = json.loads(jwt_result.content[0].text)
assert "results" in opaque_data
assert "results" in jwt_data
# Results should be the same (same user, same notes)
assert len(opaque_data["results"]) == len(jwt_data["results"])
logger.info(
"Verified opaque and JWT tokens provide identical functionality: "
f"{len(opaque_data['results'])} notes accessible from both servers"
)
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
"""Test error handling with JWT authentication."""
# Try to get a non-existent note
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_get_note", arguments={"note_id": 999999}
)
# Should get an error (note doesn't exist)
assert result.isError is True
logger.info("JWT server correctly handles errors for invalid operations")
async def test_jwt_scope_enforcement(nc_mcp_oauth_jwt_client):
"""Test that JWT server properly enforces scopes."""
# This test assumes the JWT token has both nc:read and nc:write scopes
# Both read and write operations should succeed
# Read operation
read_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert read_result.isError is False
# Write operation
import uuid
note_title = f"Scope Test {uuid.uuid4().hex[:8]}"
write_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_create_note",
arguments={
"title": note_title,
"content": "Testing scope enforcement",
"category": "Testing",
},
)
assert write_result.isError is False
# Clean up
note_id = json.loads(write_result.content[0].text)["id"]
await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
logger.info("JWT server properly allows operations based on token scopes")
async def test_jwt_automation_worked(nc_mcp_oauth_jwt_client):
"""Test that verifies the automated JWT client creation worked correctly.
This test confirms that:
1. JWT client was auto-created during container initialization
2. MCP server loaded credentials from auto-generated file
3. JWT authentication flow works end-to-end
4. Server uses JWT tokens (not opaque tokens)
"""
# If we can connect and execute tools, the automation worked
result = await nc_mcp_oauth_jwt_client.list_tools()
assert result is not None
assert len(result.tools) > 0
# Execute a tool to verify full OAuth flow
tool_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert tool_result.isError is False
logger.info(
"✅ JWT client automation successful! "
"Auto-generated credentials working correctly."
)
+358
View File
@@ -0,0 +1,358 @@
"""
Multi-user OAuth tests for Nextcloud Deck board permissions.
Tests verify that the MCP server respects Nextcloud Deck board ACL permissions
when accessed via OAuth authentication with different users.
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def add_board_acl(nc_client, board_id: int, user: str, permission_type: int = 0):
"""
Helper to add ACL entry to a Deck board.
Args:
nc_client: Admin NextcloudClient
board_id: Board ID
user: Username to grant access
permission_type: 0=view, 1=edit, 2=manage
Returns:
ACL entry ID
"""
acl = await nc_client.deck.add_acl_rule(
board_id=board_id,
type=0, # 0 = user, 1 = group
participant=user,
permission_edit=permission_type >= 1,
permission_share=permission_type >= 2,
permission_manage=permission_type >= 2,
)
logger.info(f"Added ACL for board {board_id}: {user} (type={permission_type})")
return acl.id
async def delete_board_acl(nc_client, board_id: int, acl_id: int):
"""Helper to delete a board ACL entry."""
await nc_client.deck.delete_acl_rule(board_id, acl_id)
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
@pytest.mark.anyio
async def test_deck_board_view_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
"""
Test that Deck boards respect view permissions.
Scenario:
1. Admin creates a board as alice
2. Admin adds bob to board with view-only permissions
3. Bob can view the board via MCP tools
4. Diana cannot access the board (no ACL entry)
"""
# Create a board as alice
logger.info("Creating Deck board as alice...")
board = await nc_client.deck.create_board(
"Alice's Shared Board - View Test", "FF0000"
)
board_id = board.id
bob_acl_id = None
try:
# Add bob to board with view-only permission
logger.info("Adding bob to board with view permission...")
bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
# Test: Bob can view the board via MCP
logger.info("Bob attempting to list boards via MCP...")
result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
# Bob should see the shared board
if board_id in board_ids:
logger.info(f"Bob can see shared board {board_id}")
else:
logger.warning(f"Bob cannot see shared board {board_id}")
else:
logger.warning(f"Bob could not list boards: {result.content}")
# Test: Diana cannot see the board
logger.info("Diana attempting to list boards via MCP...")
result = await diana_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Diana can see {len(response_data)} boards")
# Diana should NOT see the board
assert board_id not in board_ids, "Diana should not see board without ACL"
logger.info("Diana correctly cannot see board without ACL")
else:
logger.warning(f"Diana could not list boards: {result.content}")
finally:
# Cleanup
if bob_acl_id:
await delete_board_acl(nc_client, board_id, bob_acl_id)
logger.info(f"Deleting board {board_id}")
await nc_client.deck.delete_board(board_id)
@pytest.mark.anyio
async def test_deck_board_edit_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
"""
Test that Deck boards respect edit permissions.
Scenario:
1. Admin creates a board as alice with a stack
2. Admin adds charlie with edit permission
3. Admin adds bob with view-only permission
4. Charlie can create cards via MCP tools
5. Bob cannot create cards
"""
# Create a board as alice
logger.info("Creating Deck board as alice...")
board = await nc_client.deck.create_board(
"Alice's Shared Board - Edit Test", "00FF00"
)
board_id = board.id
# Create a stack in the board
logger.info("Creating stack in board...")
stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1)
stack_id = stack.id
charlie_acl_id = None
bob_acl_id = None
try:
# Add charlie with edit permission
logger.info("Adding charlie to board with edit permission...")
charlie_acl_id = await add_board_acl(
nc_client, board_id, "charlie", permission_type=1
)
# Add bob with view-only permission
logger.info("Adding bob to board with view permission...")
bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
# Test: Charlie can create a card
logger.info("Charlie attempting to create card via MCP...")
result = await charlie_mcp_client.call_tool(
"deck_create_card",
arguments={
"board_id": board_id,
"stack_id": stack_id,
"title": "Charlie's Card",
"description": "Created by Charlie with edit permission",
},
)
if not result.isError:
response_data = json.loads(result.content[0].text)
card_id = response_data.get("id")
logger.info(f"Charlie successfully created card {card_id}")
# Cleanup the card
await nc_client.deck.delete_card(board_id, stack_id, card_id)
else:
logger.warning(f"Charlie could not create card: {result.content}")
# Test: Bob attempts to create a card (should fail)
logger.info("Bob attempting to create card via MCP...")
result = await bob_mcp_client.call_tool(
"deck_create_card",
arguments={
"board_id": board_id,
"stack_id": stack_id,
"title": "Bob's Card",
"description": "Bob trying to create a card",
},
)
if result.isError:
logger.info("Bob correctly denied card creation (view-only)")
else:
logger.warning("Bob unexpectedly succeeded in creating card")
# Cleanup if bob somehow created a card
response_data = json.loads(result.content[0].text)
if "id" in response_data:
await nc_client.deck.delete_card(
board_id, stack_id, response_data["id"]
)
finally:
# Cleanup
if charlie_acl_id:
await delete_board_acl(nc_client, board_id, charlie_acl_id)
if bob_acl_id:
await delete_board_acl(nc_client, board_id, bob_acl_id)
logger.info(f"Deleting board {board_id}")
await nc_client.deck.delete_board(board_id)
@pytest.mark.anyio
async def test_deck_board_manage_permissions(
nc_client, alice_mcp_client, charlie_mcp_client
):
"""
Test that Deck boards respect manage permissions.
Scenario:
1. Admin creates a board as alice
2. Admin adds charlie with manage permission
3. Charlie can create stacks and modify board settings
"""
# Create a board as alice
logger.info("Creating Deck board as alice...")
board = await nc_client.deck.create_board(
"Alice's Shared Board - Manage Test", "0000FF"
)
board_id = board.id
charlie_acl_id = None
try:
# Add charlie with manage permission
logger.info("Adding charlie to board with manage permission...")
charlie_acl_id = await add_board_acl(
nc_client, board_id, "charlie", permission_type=2
)
# Test: Charlie can create a stack
logger.info("Charlie attempting to create stack via MCP...")
result = await charlie_mcp_client.call_tool(
"deck_create_stack",
arguments={"board_id": board_id, "title": "Charlie's Stack", "order": 1},
)
if not result.isError:
response_data = json.loads(result.content[0].text)
stack_id = response_data.get("id")
logger.info(f"Charlie successfully created stack {stack_id}")
# Cleanup the stack
await nc_client.deck.delete_stack(board_id, stack_id)
else:
logger.warning(f"Charlie could not create stack: {result.content}")
# Test: Charlie can delete a stack (manage permission)
logger.info("Charlie attempting to delete stack via MCP...")
# First create a temporary stack to delete
temp_stack = await nc_client.deck.create_stack(
board_id, "Temp Stack for Deletion", 99
)
result = await charlie_mcp_client.call_tool(
"deck_delete_stack",
arguments={"board_id": board_id, "stack_id": temp_stack.id},
)
if not result.isError:
logger.info("Charlie successfully deleted stack")
else:
logger.warning(f"Charlie could not delete stack: {result.content}")
# Cleanup if deletion via MCP failed
try:
await nc_client.deck.delete_stack(board_id, temp_stack.id)
except Exception:
pass
finally:
# Cleanup
if charlie_acl_id:
await delete_board_acl(nc_client, board_id, charlie_acl_id)
logger.info(f"Deleting board {board_id}")
await nc_client.deck.delete_board(board_id)
@pytest.mark.anyio
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own boards when not shared.
Scenario:
1. Admin creates a board as alice (not shared)
2. Admin creates a board as bob (not shared)
3. Alice can only see her own board
4. Bob can only see his own board
"""
# Create alice's board
logger.info("Creating alice's private board...")
alice_board = await nc_client.deck.create_board("Alice's Private Board", "FF00FF")
alice_board_id = alice_board.id
# Create bob's board
logger.info("Creating bob's private board...")
bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF")
bob_board_id = bob_board.id
try:
# Test: Alice lists boards
logger.info("Alice listing boards via MCP...")
result = await alice_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Alice can see boards: {board_ids}")
# Alice should NOT see Bob's board
assert bob_board_id not in board_ids, (
"Alice should not see Bob's private board"
)
else:
logger.warning(f"Alice could not list boards: {result.content}")
# Test: Bob lists boards
logger.info("Bob listing boards via MCP...")
result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
if not result.isError:
response_data = json.loads(result.content[0].text)
# The response is directly a list of boards
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
board_ids = [b["id"] for b in response_data]
logger.info(f"Bob can see boards: {board_ids}")
# Bob should NOT see Alice's board
assert alice_board_id not in board_ids, (
"Bob should not see Alice's private board"
)
else:
logger.warning(f"Bob could not list boards: {result.content}")
logger.info("User isolation test passed: users can only see their own boards")
finally:
# Cleanup
logger.info("Cleaning up test boards...")
await nc_client.deck.delete_board(alice_board_id)
await nc_client.deck.delete_board(bob_board_id)
+425
View File
@@ -0,0 +1,425 @@
"""
Multi-user OAuth tests for Nextcloud WebDAV file permissions.
Tests verify that the MCP server respects Nextcloud file sharing permissions
when accessed via OAuth authentication with different users.
All operations (file creation, sharing, access) are performed through MCP tools
to ensure the MCP server properly supports multi-user scenarios.
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.anyio
async def test_file_share_read_permissions(
alice_mcp_client, bob_mcp_client, diana_mcp_client
):
"""
Test that shared files respect read permissions.
Scenario:
1. Alice creates a file via MCP
2. Alice shares the file with Bob (read-only) via MCP
3. Bob can read the file via MCP tools
4. Diana cannot access the file (no share)
"""
file_path = "/alice_shared_file_read.txt"
file_content = "This file is shared with Bob for reading only."
# Alice creates a file
logger.info(f"Alice creating file: {file_path}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
share_id = None
try:
# Alice shares the file with bob (read-only, permissions=1)
logger.info("Alice sharing file with bob (read-only)...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to create share: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
logger.info(f"Created share {share_id}")
# Test: Bob reads the file via MCP
logger.info("Bob attempting to read file via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_path}
)
# Bob should be able to read the shared file
if not result.isError:
response_data = json.loads(result.content[0].text)
logger.info(
f"Bob successfully read file: {response_data.get('content', '')[:50]}..."
)
assert "content" in response_data
assert file_content in response_data["content"]
else:
logger.warning(f"Bob could not read file: {result.content}")
# This might fail if the share path is different for bob
# Test: Diana attempts to read the file
logger.info("Diana attempting to read file via MCP...")
result = await diana_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_path}
)
# Diana should NOT be able to read (no share)
if result.isError:
logger.info("Diana correctly denied access to unshared file")
else:
logger.warning("Diana unexpectedly could read unshared file")
finally:
# Cleanup - Alice deletes the share and file
if share_id:
logger.info(f"Alice deleting share {share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
logger.info(f"Alice deleting file {file_path}")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": file_path}
)
@pytest.mark.anyio
async def test_file_share_write_permissions(
alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
"""
Test that shared files respect write permissions.
Scenario:
1. Alice creates a file via MCP
2. Alice shares the file with Charlie (edit permission) via MCP
3. Alice shares the file with Bob (read-only) via MCP
4. Charlie can edit the file via MCP tools
5. Bob cannot edit the file
"""
file_path = "/alice_shared_file_write.txt"
file_content = "This file is shared with Charlie for editing."
logger.info(f"Alice creating file: {file_path}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
charlie_share_id = None
bob_share_id = None
try:
# Alice shares with Charlie (read+write, permissions=3)
logger.info("Alice sharing file with Charlie (edit permission)...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "charlie",
"share_type": 0,
"permissions": 3,
},
)
assert not result.isError, (
f"Alice failed to share with Charlie: {result.content}"
)
charlie_share_data = json.loads(result.content[0].text)
charlie_share_id = charlie_share_data["id"]
logger.info(f"Created share {charlie_share_id} for Charlie")
# Alice shares with Bob (read-only, permissions=1)
logger.info("Alice sharing file with Bob (read-only)...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": file_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to share with Bob: {result.content}"
bob_share_data = json.loads(result.content[0].text)
bob_share_id = bob_share_data["id"]
logger.info(f"Created share {bob_share_id} for Bob")
# Test: Charlie can write to the file
logger.info("Charlie attempting to write to file via MCP...")
updated_content = f"{file_content}\nCharlie added this line."
result = await charlie_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": updated_content},
)
if not result.isError:
logger.info("Charlie successfully wrote to file")
else:
logger.warning(f"Charlie could not write to file: {result.content}")
# Test: Bob attempts to write (should fail)
logger.info("Bob attempting to write to file via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_path, "content": "Bob tries to overwrite this."},
)
# Bob should be denied
if result.isError:
logger.info("Bob correctly denied write access")
else:
logger.warning("Bob unexpectedly succeeded in writing (permissions issue?)")
finally:
# Cleanup - Alice deletes shares and file
if charlie_share_id:
logger.info(f"Alice deleting Charlie's share {charlie_share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": charlie_share_id}
)
if bob_share_id:
logger.info(f"Alice deleting Bob's share {bob_share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": bob_share_id}
)
logger.info(f"Alice deleting file {file_path}")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": file_path}
)
@pytest.mark.anyio
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that file listing respects share permissions.
Scenario:
1. Alice creates her private file via MCP
2. Bob creates his private file via MCP
3. Alice creates a file and shares it with Bob via MCP
4. Alice can list her own files + shared files
5. Bob can list his own files + shared files from Alice
"""
alice_file = "/alice_private_file.txt"
bob_file = "/bob_private_file.txt"
shared_file = "/alice_shared_with_bob.txt"
# Alice creates her private file
logger.info(f"Alice creating private file: {alice_file}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": alice_file, "content": "Alice's private file"},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
# Bob creates his private file
logger.info(f"Bob creating private file: {bob_file}")
result = await bob_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": bob_file, "content": "Bob's private file"},
)
assert not result.isError, f"Bob failed to create file: {result.content}"
# Alice creates a shared file
logger.info(f"Alice creating shared file: {shared_file}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": shared_file, "content": "Shared file content"},
)
assert not result.isError, f"Alice failed to create shared file: {result.content}"
share_id = None
try:
# Alice shares the file with Bob
logger.info("Alice sharing file with Bob...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": shared_file,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to create share: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
# Test: Alice lists files in root
logger.info("Alice listing files via MCP...")
result = await alice_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
file_names = [f["name"] for f in response_data]
logger.info(f"Alice can see files: {file_names}")
# Alice should see her own files
# Note: Exact assertions depend on test isolation
else:
logger.warning(f"Alice could not list files: {result.content}")
# Test: Bob lists files in root
logger.info("Bob listing files via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
file_names = [f["name"] for f in response_data]
logger.info(f"Bob can see files: {file_names}")
# Bob should see his own file, but not Alice's private file
# Bob may see shared files in his shared folder or via different path
else:
logger.warning(f"Bob could not list files: {result.content}")
finally:
# Cleanup
if share_id:
logger.info(f"Alice deleting share {share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
logger.info("Cleaning up Alice's files...")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": alice_file}
)
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": shared_file}
)
logger.info("Cleaning up Bob's files...")
await bob_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": bob_file}
)
@pytest.mark.anyio
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that folder sharing works correctly.
Scenario:
1. Alice creates a folder via MCP
2. Alice creates files in the folder via MCP
3. Alice shares the folder with Bob via MCP
4. Bob can access files in the shared folder via MCP
"""
folder_path = "/alice_shared_folder"
file_in_folder = f"{folder_path}/document.txt"
file_content = "This is a document in Alice's shared folder"
# Alice creates folder
logger.info(f"Alice creating folder: {folder_path}")
result = await alice_mcp_client.call_tool(
"nc_webdav_create_directory", arguments={"path": folder_path}
)
assert not result.isError, f"Alice failed to create folder: {result.content}"
# Alice creates file in folder
logger.info(f"Alice creating file in folder: {file_in_folder}")
result = await alice_mcp_client.call_tool(
"nc_webdav_write_file",
arguments={"path": file_in_folder, "content": file_content},
)
assert not result.isError, f"Alice failed to create file: {result.content}"
share_id = None
try:
# Alice shares the folder with Bob
logger.info("Alice sharing folder with Bob...")
result = await alice_mcp_client.call_tool(
"nc_share_create",
arguments={
"path": folder_path,
"share_with": "bob",
"share_type": 0,
"permissions": 1,
},
)
assert not result.isError, f"Alice failed to create share: {result.content}"
share_data = json.loads(result.content[0].text)
share_id = share_data["id"]
logger.info(f"Created folder share {share_id}")
# Test: Bob lists the shared folder
logger.info("Bob attempting to list shared folder via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_list_directory", arguments={"path": folder_path}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
if not isinstance(response_data, list):
response_data = [response_data] if response_data else []
logger.info(f"Bob can see {len(response_data)} files in shared folder")
# Bob should see the file in the shared folder
file_names = [f["name"] for f in response_data]
assert "document.txt" in file_names, (
"Bob should see the file in shared folder"
)
else:
logger.warning(f"Bob could not list shared folder: {result.content}")
# Test: Bob reads the file in the shared folder
logger.info("Bob attempting to read file in shared folder via MCP...")
result = await bob_mcp_client.call_tool(
"nc_webdav_read_file", arguments={"path": file_in_folder}
)
if not result.isError:
response_data = json.loads(result.content[0].text)
logger.info("Bob successfully read file in shared folder")
assert "content" in response_data
assert file_content in response_data["content"]
else:
logger.warning(
f"Bob could not read file in shared folder: {result.content}"
)
finally:
# Cleanup - Alice deletes the share and folder
if share_id:
logger.info(f"Alice deleting share {share_id}")
await alice_mcp_client.call_tool(
"nc_share_delete", arguments={"share_id": share_id}
)
logger.info("Alice cleaning up test folder...")
await alice_mcp_client.call_tool(
"nc_webdav_delete_resource", arguments={"path": folder_path}
)
@@ -0,0 +1,260 @@
"""
Multi-user OAuth tests for Nextcloud Notes permissions.
Tests verify that the MCP server respects Nextcloud Notes sharing permissions
when accessed via OAuth authentication with different users.
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.anyio
async def test_notes_share_read_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
"""
Test that shared notes respect read permissions.
Scenario:
1. Admin creates a note as alice
2. Admin shares the note with bob (read-only)
3. Bob can read the note via MCP tools
4. Diana cannot access the note (no share)
"""
# Create a note as alice (using admin client to set up data)
note_title = "Alice's Shared Note - Read Test"
note_content = "This note is shared with Bob for reading only."
note_category = "SharedNotes"
logger.info("Creating note as alice...")
created_note = await nc_client.notes.create_note(
title=note_title, content=note_content, category=note_category
)
note_id = created_note.get("id")
try:
# TODO: Share the note with bob (read-only)
# Note: Nextcloud Notes API doesn't have direct sharing endpoints
# Sharing is typically done at the folder level via WebDAV
# For now, this test documents the expected behavior
# Test: Bob searches for notes via MCP
logger.info("Bob searching for notes via MCP...")
result = await bob_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": "Alice's Shared"}
)
assert result.isError is False, f"Bob's search failed: {result.content}"
response_data = json.loads(result.content[0].text)
# Bob should see the shared note in search results
# (assuming proper share setup)
assert "results" in response_data
logger.info(f"Bob found {len(response_data['results'])} notes")
# Test: Diana searches for the same note
logger.info("Diana searching for notes via MCP...")
result = await diana_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": "Alice's Shared"}
)
assert result.isError is False
response_data = json.loads(result.content[0].text)
# Diana should NOT see the note (no share)
assert "results" in response_data
shared_note_ids = [
n["id"] for n in response_data["results"] if n["id"] == note_id
]
assert len(shared_note_ids) == 0, "Diana should not see unshared note"
logger.info("Diana correctly cannot see unshared note")
finally:
# Cleanup
logger.info(f"Cleaning up note {note_id}")
await nc_client.notes.delete_note(note_id)
@pytest.mark.anyio
async def test_notes_share_write_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
"""
Test that shared notes respect write permissions.
Scenario:
1. Admin creates a note as alice
2. Admin shares the note with charlie (edit permission)
3. Admin shares the note with bob (read-only)
4. Charlie can edit the note via MCP tools
5. Bob cannot edit the note
"""
# Create a note as alice
note_title = "Alice's Shared Note - Write Test"
note_content = "This note is shared with Charlie for editing."
note_category = "SharedNotes"
logger.info("Creating note as alice...")
created_note = await nc_client.notes.create_note(
title=note_title, content=note_content, category=note_category
)
note_id = created_note.get("id")
try:
# TODO: Share the note with charlie (edit permission) and bob (read-only)
# Note: Nextcloud Notes sharing is folder-based
# Test: Charlie can append content to the note
logger.info("Charlie attempting to append content via MCP...")
result = await charlie_mcp_client.call_tool(
"nc_notes_append_content",
arguments={
"note_id": note_id,
"content": "\n\nCharlie added this content.",
},
)
# If sharing is properly configured, Charlie should succeed
# Without proper sharing setup, this will fail
logger.info(f"Charlie's append result: isError={result.isError}")
if not result.isError:
logger.info("Charlie successfully appended content (shares configured)")
else:
logger.warning("Charlie could not append (shares not yet configured)")
# Test: Bob attempts to append content (should fail)
logger.info("Bob attempting to append content via MCP...")
result = await bob_mcp_client.call_tool(
"nc_notes_append_content",
arguments={"note_id": note_id, "content": "\n\nBob tried to add this."},
)
# Bob should fail (read-only access)
logger.info(f"Bob's append result: isError={result.isError}")
if result.isError:
logger.info("Bob correctly denied write access")
else:
logger.warning("Bob unexpectedly succeeded (permissions issue?)")
finally:
# Cleanup
logger.info(f"Cleaning up note {note_id}")
await nc_client.notes.delete_note(note_id)
@pytest.mark.anyio
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own notes when not shared.
Scenario:
1. Admin creates a note as alice (not shared)
2. Admin creates a note as bob (not shared)
3. Alice can only see her own note
4. Bob can only see his own note
"""
# Create alice's note
logger.info("Creating alice's private note...")
alice_note = await nc_client.notes.create_note(
title="Alice's Private Note",
content="This is Alice's private content.",
category="AlicePrivate",
)
alice_note_id = alice_note.get("id")
# Create bob's note
logger.info("Creating bob's private note...")
bob_note = await nc_client.notes.create_note(
title="Bob's Private Note",
content="This is Bob's private content.",
category="BobPrivate",
)
bob_note_id = bob_note.get("id")
try:
# Test: Alice searches all notes
logger.info("Alice searching all notes via MCP...")
result = await alice_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False
response_data = json.loads(result.content[0].text)
alice_notes = response_data.get("results", [])
alice_note_ids = [n["id"] for n in alice_notes]
logger.info(f"Alice can see {len(alice_notes)} notes")
# Alice should NOT see Bob's note
assert bob_note_id not in alice_note_ids, (
"Alice should not see Bob's private note"
)
# Test: Bob searches all notes
logger.info("Bob searching all notes via MCP...")
result = await bob_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False
response_data = json.loads(result.content[0].text)
bob_notes = response_data.get("results", [])
bob_note_ids = [n["id"] for n in bob_notes]
logger.info(f"Bob can see {len(bob_notes)} notes")
# Bob should NOT see Alice's note
assert alice_note_id not in bob_note_ids, (
"Bob should not see Alice's private note"
)
logger.info("User isolation test passed: users can only see their own notes")
finally:
# Cleanup
logger.info("Cleaning up test notes...")
await nc_client.notes.delete_note(alice_note_id)
await nc_client.notes.delete_note(bob_note_id)
@pytest.mark.anyio
async def test_oauth_mcp_clients_initialized(
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
):
"""
Smoke test to verify all OAuth MCP clients are properly initialized.
"""
logger.info("Testing alice_mcp_client initialization...")
result = await alice_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Alice MCP client failed: {result.content}"
logger.info("Alice MCP client working")
logger.info("Testing bob_mcp_client initialization...")
result = await bob_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Bob MCP client failed: {result.content}"
logger.info("Bob MCP client working")
logger.info("Testing charlie_mcp_client initialization...")
result = await charlie_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Charlie MCP client failed: {result.content}"
logger.info("Charlie MCP client working")
logger.info("Testing diana_mcp_client initialization...")
result = await diana_mcp_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Diana MCP client failed: {result.content}"
logger.info("Diana MCP client working")
logger.info("All OAuth MCP clients successfully initialized!")
+539
View File
@@ -0,0 +1,539 @@
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
These tests verify:
1. Dynamic tool filtering based on user's token scopes
2. Scope enforcement (403 responses for insufficient scopes)
3. Protected Resource Metadata (PRM) endpoint
4. WWW-Authenticate challenge headers
5. BasicAuth bypass (all tools visible)
"""
import pytest
@pytest.mark.integration
async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data."""
import httpx
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
async with httpx.AsyncClient() as client:
response = await client.get(
"http://localhost:8001/.well-known/oauth-protected-resource/mcp"
)
assert response.status_code == 200
prm_data = response.json()
assert prm_data["resource"] == "http://localhost:8001/mcp"
assert "nc:read" in prm_data["scopes_supported"]
assert "nc:write" in prm_data["scopes_supported"]
assert "http://localhost:8080" in prm_data["authorization_servers"]
assert "header" in prm_data["bearer_methods_supported"]
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
@pytest.mark.integration
async def test_basicauth_shows_all_tools(nc_mcp_client):
"""Test that BasicAuth mode shows all tools (no filtering)."""
# Note: Don't use 'async with' for session-scoped fixtures
# The fixture itself manages the session lifecycle
# List all tools
tools_response = await nc_mcp_client.list_tools()
# BasicAuth should see all tools
tool_names = [tool.name for tool in tools_response.tools]
# Should see both read and write tools
assert "nc_notes_get_note" in tool_names # read tool
assert "nc_notes_create_note" in tool_names # write tool
assert "nc_calendar_list_calendars" in tool_names # read tool
assert "nc_calendar_create_event" in tool_names # write tool
# Should have all 90+ tools
assert len(tool_names) >= 90
@pytest.mark.integration
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
"""Test that a token with only nc:read scope filters out write tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has only "nc:read" scope
result = await nc_mcp_oauth_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Read-only token sees {len(tool_names)} tools")
# Verify read tools are present
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
# Verify write tools are NOT present
write_tools_should_be_filtered = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in write_tools_should_be_filtered:
assert tool not in tool_names, (
f"Write tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, "
f"write tools hidden"
)
@pytest.mark.integration
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
"""Test that a token with only nc:write scope filters out read tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has only "nc:write" scope
result = await nc_mcp_oauth_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Write-only token sees {len(tool_names)} tools")
# Verify write tools are present
expected_write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
# Verify read tools are NOT present (write-only scope)
read_tools_should_be_filtered = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in read_tools_should_be_filtered:
assert tool not in tool_names, (
f"Read tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, "
f"read tools hidden"
)
@pytest.mark.integration
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
"""Test that a token with both nc:read and nc:write scopes can see all tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has both "nc:read" and "nc:write" scopes
result = await nc_mcp_oauth_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Full access token sees {len(tool_names)} tools")
# Verify both read and write tools are present
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_webdav_read_file",
]
expected_write_tools = [
"nc_notes_create_note",
"nc_calendar_create_event",
"nc_webdav_write_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all 90+ tools (both read and write)
assert len(tool_names) >= 90
logger.info(
f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)"
)
@pytest.mark.integration
async def test_scope_helper_functions():
"""Test the scope authorization helper functions."""
from nextcloud_mcp_server.auth import get_required_scopes, has_required_scopes
# Create a mock function with scope requirements
async def mock_read_tool():
pass
async def mock_write_tool():
pass
async def mock_no_scope_tool():
pass
# Add scope metadata
mock_read_tool._required_scopes = ["nc:read"] # type: ignore
mock_write_tool._required_scopes = ["nc:write"] # type: ignore
# Test get_required_scopes
assert get_required_scopes(mock_read_tool) == ["nc:read"]
assert get_required_scopes(mock_write_tool) == ["nc:write"]
assert get_required_scopes(mock_no_scope_tool) == []
# Test has_required_scopes
read_only_scopes = {"nc:read"}
full_scopes = {"nc:read", "nc:write"}
no_scopes = set()
# User with only read scope
assert has_required_scopes(mock_read_tool, read_only_scopes) is True
assert has_required_scopes(mock_write_tool, read_only_scopes) is False
assert has_required_scopes(mock_no_scope_tool, read_only_scopes) is True
# User with full scopes
assert has_required_scopes(mock_read_tool, full_scopes) is True
assert has_required_scopes(mock_write_tool, full_scopes) is True
assert has_required_scopes(mock_no_scope_tool, full_scopes) is True
# User with no scopes
assert has_required_scopes(mock_read_tool, no_scopes) is False
assert has_required_scopes(mock_write_tool, no_scopes) is False
assert has_required_scopes(mock_no_scope_tool, no_scopes) is True
@pytest.mark.integration
async def test_scope_decorator_stores_metadata():
"""Test that @require_scopes decorator properly stores metadata."""
from nextcloud_mcp_server.auth import require_scopes
@require_scopes("nc:read", "nc:write")
async def test_function():
pass
# Check that metadata was stored
assert hasattr(test_function, "_required_scopes")
assert test_function._required_scopes == ["nc:read", "nc:write"]
@pytest.mark.integration
async def test_tools_have_scope_decorators(nc_mcp_client):
"""Test that MCP tools have scope requirements defined."""
# Note: Don't use 'async with' for session-scoped fixtures
# The fixture itself manages the session lifecycle
# We can at least verify that some expected tools exist
tools_response = await nc_mcp_client.list_tools()
tool_names = [tool.name for tool in tools_response.tools]
# Verify expected read tools exist
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_contacts_list_contacts",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
# Verify expected write tools exist
expected_write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_contacts_create_contact",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
@pytest.mark.integration
async def test_scope_classification():
"""Test that our scope classification correctly identifies read vs write operations."""
from scripts.add_scope_decorators_simple import classify_function
# Test read operations
assert classify_function("nc_notes_get_note") == "nc:read"
assert classify_function("nc_notes_search_notes") == "nc:read"
assert classify_function("nc_calendar_list_events") == "nc:read"
assert classify_function("nc_webdav_read_file") == "nc:read"
assert classify_function("nc_calendar_find_availability") == "nc:read"
assert classify_function("nc_calendar_get_upcoming_events") == "nc:read"
# Test write operations
assert classify_function("nc_notes_create_note") == "nc:write"
assert classify_function("nc_notes_update_note") == "nc:write"
assert classify_function("nc_notes_delete_note") == "nc:write"
assert classify_function("nc_notes_append_content") == "nc:write"
assert classify_function("nc_calendar_create_event") == "nc:write"
assert classify_function("nc_calendar_update_event") == "nc:write"
assert classify_function("nc_calendar_manage_calendar") == "nc:write"
assert classify_function("nc_webdav_write_file") == "nc:write"
assert classify_function("nc_webdav_move_resource") == "nc:write"
assert classify_function("nc_contacts_create_contact") == "nc:write"
assert classify_function("nc_cookbook_import_recipe") == "nc:write"
assert classify_function("nc_tables_insert_row") == "nc:write"
assert classify_function("deck_archive_card") == "nc:write"
assert classify_function("deck_assign_label_to_card") == "nc:write"
@pytest.mark.integration
async def test_all_tools_classified():
"""Verify that all tools can be properly classified as read or write."""
from scripts.add_scope_decorators_simple import classify_function
# List of all tool names (extracted from our implementation)
all_tools = [
# Calendar tools
"nc_calendar_list_calendars",
"nc_calendar_create_event",
"nc_calendar_list_events",
"nc_calendar_get_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_calendar_create_meeting",
"nc_calendar_get_upcoming_events",
"nc_calendar_find_availability",
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"nc_calendar_list_todos",
"nc_calendar_create_todo",
"nc_calendar_update_todo",
"nc_calendar_delete_todo",
"nc_calendar_search_todos",
# Notes tools
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_append_content",
"nc_notes_delete_note",
"nc_notes_get_attachment",
# Add more as needed...
]
unclassified = []
for tool_name in all_tools:
scope = classify_function(tool_name)
if scope is None:
unclassified.append(tool_name)
# All tools should be classifiable
assert len(unclassified) == 0, f"Unclassified tools: {unclassified}"
@pytest.mark.integration
async def test_scope_metadata_coverage(nc_mcp_client):
"""Test that all tools have scope metadata defined (no undecorated tools)."""
# This test would require access to the actual tool functions to check metadata
# For now, we verify that the expected number of tools exists
# Note: Don't use 'async with' for session-scoped fixtures
tools_response = await nc_mcp_client.list_tools()
# We applied decorators to 90 tools
# In BasicAuth mode, all should be visible
assert len(tools_response.tools) >= 90
@pytest.mark.integration
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
nc_mcp_oauth_client_no_custom_scopes,
):
"""
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
This tests the security behavior when a user declines to grant custom scopes during consent.
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write.
All tools require at least one custom scope, so they should all be filtered out.
"""
import logging
logger = logging.getLogger(__name__)
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
result = await nc_mcp_oauth_client_no_custom_scopes.list_tools()
assert result is not None
tool_names = [tool.name for tool in result.tools]
logger.info(
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
)
# All tools require nc:read or nc:write, so should be filtered out
assert len(tool_names) == 0, (
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
)
logger.info(
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
"""
Test JWT with only nc:read scope consented.
Simulates user granting only read permission during OAuth consent.
Expected: Should see read tools but not write tools.
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools")
# Verify read tools are present
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"]
for tool in read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
# Verify write tools are filtered out
write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_webdav_write_file",
]
for tool in write_tools:
assert tool not in tool_names, f"Write tool {tool} should be filtered out"
logger.info(
f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
"""
Test JWT with only nc:write scope consented.
Simulates user granting only write permission during OAuth consent.
Expected: Should see write tools but not read-only tools.
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools")
# Verify write tools are present
write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_webdav_write_file",
]
for tool in write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Verify read-only tools are filtered out
read_only_tools = ["nc_notes_get_note", "nc_notes_search_notes"]
for tool in read_only_tools:
assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
logger.info(
f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
"""
Test JWT with both nc:read and nc:write scopes consented.
Simulates user granting both permissions during OAuth consent.
Expected: Should see all 90+ tools (both read and write).
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with full consent sees {len(tool_names)} tools")
# Verify both read and write tools are present
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"]
write_tools = ["nc_notes_create_note", "nc_webdav_write_file"]
for tool in read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all tools
assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
logger.info(
f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+109
View File
@@ -0,0 +1,109 @@
import pytest
from nextcloud_mcp_server.client import NextcloudClient
@pytest.mark.anyio
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
"""Test creating a user and verifying deletion (cleanup by fixture)."""
user_config = test_user
# Create user
await nc_client.users.create_user(**user_config)
# Verify user exists
users = await nc_client.users.search_users(search=user_config["userid"])
assert user_config["userid"] in users
user_details = await nc_client.users.get_user_details(user_config["userid"])
assert user_details.id == user_config["userid"]
assert user_details.displayname == user_config["display_name"]
assert user_details.email == user_config["email"]
# Test deletion explicitly as part of test functionality
await nc_client.users.delete_user(user_config["userid"])
# Verify user is deleted
users = await nc_client.users.search_users(search=user_config["userid"])
assert user_config["userid"] not in users
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
@pytest.mark.anyio
async def test_update_user_field(nc_client: NextcloudClient, test_user):
"""Test updating user fields."""
user_config = test_user
await nc_client.users.create_user(**user_config)
new_email = f"new.{user_config['email']}"
await nc_client.users.update_user_field(user_config["userid"], "email", new_email)
user_details = await nc_client.users.get_user_details(user_config["userid"])
assert user_details.email == new_email
# Fixture will handle cleanup
@pytest.mark.anyio
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
"""Test adding and removing users from groups."""
user_config, groupid = test_user_in_group
userid = user_config["userid"]
# Verify user is in group
groups = await nc_client.users.get_user_groups(userid)
assert groupid in groups
# Remove user from group
await nc_client.users.remove_user_from_group(userid, groupid)
groups = await nc_client.users.get_user_groups(userid)
assert groupid not in groups
# Fixtures will handle cleanup
@pytest.mark.anyio
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
"""Test promoting and demoting subadmins."""
user_config = test_user
groupid = test_group
userid = user_config["userid"]
await nc_client.users.create_user(**user_config)
# Promote to subadmin
await nc_client.users.promote_user_to_subadmin(userid, groupid)
subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
assert groupid in subadmin_groups
# Demote from subadmin
await nc_client.users.demote_user_from_subadmin(userid, groupid)
subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
assert groupid not in subadmin_groups
# Fixtures will handle cleanup
@pytest.mark.anyio
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
"""Test disabling and enabling users."""
user_config = test_user
userid = user_config["userid"]
await nc_client.users.create_user(**user_config)
# Disable user
await nc_client.users.disable_user(userid)
user_details = await nc_client.users.get_user_details(userid)
assert not user_details.enabled
# Enable user
await nc_client.users.enable_user(userid)
user_details = await nc_client.users.get_user_details(userid)
assert user_details.enabled
# Fixture will handle cleanup
@pytest.mark.anyio
async def test_get_editable_user_fields(nc_client: NextcloudClient):
editable_fields = await nc_client.users.get_editable_user_fields()
assert "displayname" in editable_fields
assert "email" in editable_fields

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