Compare commits

..

43 Commits

Author SHA1 Message Date
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
45 changed files with 7452 additions and 858 deletions
+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_shared_test_client.json
+36
View File
@@ -1,3 +1,39 @@
## 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
+49 -39
View File
@@ -38,13 +38,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
@@ -96,18 +104,23 @@ Each Nextcloud app has a corresponding server module that:
### Testing Structure
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
- **Integration tests** in `tests/integration/` and `tests/client/`, `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
@@ -115,51 +128,48 @@ Each Nextcloud app has a corresponding server module that:
- **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 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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
FROM ghcr.io/astral-sh/uv:0.9.3-python3.11-alpine@sha256:c5c8e9241027c384aa5e0d0368a6fd013945a23b7a5f25c754ed55ea7ef64f92
WORKDIR /app
+54 -20
View File
@@ -6,6 +6,9 @@
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
> [!NOTE]
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.
## Features
### Supported Nextcloud Apps
@@ -15,6 +18,7 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models l
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. |
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
@@ -26,8 +30,16 @@ 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 patches) |
| **Basic Auth** | Lower | Development, testing, production |
> [!IMPORTANT]
> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically:
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
> - **Production use**: Wait for upstream patches to be merged into official releases
>
> 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 +70,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` + `user_oidc`)
2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
3. Enable dynamic client registration
4. Configure Bearer token validation
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 +106,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 +141,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 +158,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 +170,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 +179,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 +194,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 +247,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 app:enable cookbook
@@ -17,11 +17,6 @@ patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /dock
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
# Set the OIDC issuer URL (defaults to http://localhost:8080 if not provided)
OIDC_ISSUER="${NEXTCLOUD_PUBLIC_ISSUER_URL:-http://localhost:8080}"
php /var/www/html/occ config:app:set oidc issuer --value="${OIDC_ISSUER}"
echo "OIDC issuer set to: ${OIDC_ISSUER}"
# 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
+8 -1
View File
@@ -40,6 +40,13 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
recipes:
image: docker.io/library/nginx:alpine
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
mcp:
build: .
command: ["--transport", "streamable-http"]
@@ -63,7 +70,7 @@ services:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
# No USERNAME/PASSWORD - will use OAuth
volumes:
+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).
+4 -3
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
+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**:
+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
---
+7 -1
View File
@@ -19,8 +19,10 @@ 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,
)
@@ -375,8 +377,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,
}
@@ -442,7 +446,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(
@@ -1,6 +1,5 @@
"""Dynamic client registration for Nextcloud OIDC."""
import asyncio
import json
import logging
import os
@@ -206,73 +205,12 @@ def save_client_to_file(client_info: ClientInfo, storage_path: Path):
raise
async def wait_for_client_propagation(
nextcloud_url: str,
client_id: str,
max_retries: int = 10,
initial_delay: float = 0.5,
max_delay: float = 5.0,
) -> None:
"""
Wait for the registered OAuth client to be fully propagated in Nextcloud.
This function attempts to verify the client is ready by checking if we can
access OIDC-related endpoints. Uses exponential backoff for retries.
Args:
nextcloud_url: Base URL of the Nextcloud instance
client_id: The registered client ID
max_retries: Maximum number of retry attempts
initial_delay: Initial delay in seconds before first verification
max_delay: Maximum delay between retries
Note:
This is a best-effort approach to mitigate race conditions between
client registration and first use. Nextcloud's OIDC provider may need
time to propagate newly registered clients to its cache/database.
"""
# Always wait at least the initial delay to give Nextcloud time to propagate
logger.debug(
f"Waiting {initial_delay}s for OAuth client {client_id[:16]}... to propagate"
)
await asyncio.sleep(initial_delay)
# Verify the client is accessible by checking OIDC discovery again
# (this gives Nextcloud additional time to complete any async operations)
discovery_url = f"{nextcloud_url}/.well-known/openid-configuration"
delay = initial_delay
async with httpx.AsyncClient(timeout=10.0) as client:
for attempt in range(1, max_retries + 1):
try:
response = await client.get(discovery_url)
response.raise_for_status()
logger.debug(
f"OAuth client propagation verification successful (attempt {attempt})"
)
return
except Exception as e:
if attempt < max_retries:
delay = min(delay * 1.5, max_delay)
logger.debug(
f"Verification attempt {attempt} failed: {e}. Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
else:
logger.warning(
f"Could not verify client propagation after {max_retries} attempts. "
"Continuing anyway - first authorization may fail."
)
async def load_or_register_client(
nextcloud_url: str,
registration_endpoint: str,
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
force_register: bool = True,
wait_for_propagation: bool = True,
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
@@ -280,9 +218,8 @@ 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
4. Waits for the client to propagate (if newly registered)
5. Saves the new client credentials
3. Registers a new client if needed (no stored credentials or expired)
4. Saves the new client credentials
Args:
nextcloud_url: Base URL of the Nextcloud instance
@@ -290,8 +227,6 @@ 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
wait_for_propagation: Wait for newly registered clients to propagate (default: True)
Returns:
ClientInfo with valid credentials
@@ -302,11 +237,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...")
@@ -317,15 +251,6 @@ async def load_or_register_client(
redirect_uris=redirect_uris,
)
# Wait for client to propagate in Nextcloud's OIDC provider
# This mitigates race conditions where the client is used immediately after registration
if wait_for_propagation:
logger.info("Waiting for OAuth client to propagate in Nextcloud...")
await wait_for_client_propagation(
nextcloud_url=nextcloud_url,
client_id=client_info.client_id,
)
# Save to storage
save_client_to_file(client_info, storage_path)
+12
View File
@@ -9,15 +9,20 @@ from httpx import (
BasicAuth,
Request,
Response,
Timeout,
)
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 .webdav import WebDAVClient
from .users import UsersClient
logger = logging.getLogger(__name__)
@@ -62,6 +67,9 @@ class NextcloudClient:
auth=auth,
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(
30.0
), # 30 second timeout for all operations including recipe imports
)
# Initialize app clients
@@ -70,7 +78,11 @@ class NextcloudClient:
self.tables = TablesClient(self._client, username)
self.calendar = CalendarClient(self._client, username)
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()
+245
View File
@@ -0,0 +1,245 @@
"""Client for Nextcloud Cookbook app operations."""
import logging
from typing import Any, Dict, List
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}
)
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}")
+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"]
+222
View File
@@ -0,0 +1,222 @@
from typing import List, Optional, Dict
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
)
+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
+40
View File
@@ -0,0 +1,40 @@
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
+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",
]
+594
View File
@@ -0,0 +1,594 @@
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.cookbook import (
Category,
CookbookConfig,
CreateRecipeResponse,
DeleteRecipeResponse,
ImportRecipeResponse,
Keyword,
ListCategoriesResponse,
ListKeywordsResponse,
ListRecipesResponse,
Recipe,
RecipeStub,
ReindexResponse,
SearchRecipesResponse,
UpdateRecipeResponse,
Version,
)
logger = logging.getLogger(__name__)
def configure_cookbook_tools(mcp: FastMCP):
@mcp.resource("cookbook://version")
async def cookbook_get_version():
"""Get the Cookbook app and API version"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
version_data = await client.cookbook.get_version()
return Version(**version_data)
@mcp.resource("cookbook://config")
async def cookbook_get_config():
"""Get the Cookbook app configuration"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
config_data = await client.cookbook.get_config()
return CookbookConfig(**config_data)
@mcp.resource("nc://Cookbook/{recipe_id}")
async def nc_cookbook_get_recipe_resource(recipe_id: int):
"""Get a recipe by ID using resource URI"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
try:
recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
This extracts recipe data from websites that use schema.org Recipe markup.
Many popular recipe sites support this standard."""
client = get_client(ctx)
try:
recipe_data = await client.cookbook.import_recipe(url)
recipe = Recipe(**recipe_data)
return ImportRecipeResponse(
recipe=recipe,
recipe_id=recipe.id or "unknown",
)
except RequestError as e:
# RequestError can have empty str() - get details from exception attributes
error_detail = (
str(e)
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
)
raise McpError(
ErrorData(
code=-1,
message=f"Network error importing recipe from {url}: {error_detail}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 400:
raise McpError(
ErrorData(
code=-1,
message=f"Invalid URL or missing 'url' field: {url}",
)
)
elif e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message="A recipe with this name already exists. Import aborted.",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to import recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to import recipe from {url}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.list_recipes()
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list recipes: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
client = get_client(ctx)
try:
recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
async def nc_cookbook_create_recipe(
name: str,
description: str | None = None,
ingredients: list[str] | None = None,
instructions: list[str] | None = None,
url: str | None = None,
prep_time: str | None = None,
cook_time: str | None = None,
total_time: str | None = None,
recipe_yield: int | None = None,
category: str | None = None,
keywords: str | None = None,
ctx: Context = None,
) -> CreateRecipeResponse:
"""Create a new recipe.
Required: name
Optional: All other recipe fields following schema.org/Recipe format.
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
client = get_client(ctx)
recipe_data = {"name": name}
if description:
recipe_data["description"] = description
if ingredients:
recipe_data["recipeIngredient"] = ingredients
if instructions:
recipe_data["recipeInstructions"] = instructions
if url:
recipe_data["url"] = url
if prep_time:
recipe_data["prepTime"] = prep_time
if cook_time:
recipe_data["cookTime"] = cook_time
if total_time:
recipe_data["totalTime"] = total_time
if recipe_yield:
recipe_data["recipeYield"] = recipe_yield
if category:
recipe_data["recipeCategory"] = category
if keywords:
recipe_data["keywords"] = keywords
try:
recipe_id = await client.cookbook.create_recipe(recipe_data)
return CreateRecipeResponse(id=recipe_id)
except HTTPStatusError as e:
if e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message=f"A recipe with name '{name}' already exists",
)
)
elif e.response.status_code == 422:
raise McpError(
ErrorData(
code=-1,
message="Recipe name is required and cannot be empty",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to create recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to create recipe: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_update_recipe(
recipe_id: int,
name: str | None = None,
description: str | None = None,
ingredients: list[str] | None = None,
instructions: list[str] | None = None,
url: str | None = None,
prep_time: str | None = None,
cook_time: str | None = None,
total_time: str | None = None,
recipe_yield: int | None = None,
category: str | None = None,
keywords: str | None = None,
ctx: Context = None,
) -> UpdateRecipeResponse:
"""Update an existing recipe.
Provide only the fields you want to update. Unspecified fields remain unchanged."""
client = get_client(ctx)
# First get the current recipe
try:
current_recipe = await client.cookbook.get_recipe(recipe_id)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}",
)
)
# Update only specified fields
recipe_data = current_recipe.copy()
if name is not None:
recipe_data["name"] = name
if description is not None:
recipe_data["description"] = description
if ingredients is not None:
recipe_data["recipeIngredient"] = ingredients
if instructions is not None:
recipe_data["recipeInstructions"] = instructions
if url is not None:
recipe_data["url"] = url
if prep_time is not None:
recipe_data["prepTime"] = prep_time
if cook_time is not None:
recipe_data["cookTime"] = cook_time
if total_time is not None:
recipe_data["totalTime"] = total_time
if recipe_yield is not None:
recipe_data["recipeYield"] = recipe_yield
if category is not None:
recipe_data["recipeCategory"] = category
if keywords is not None:
recipe_data["keywords"] = keywords
try:
updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data)
return UpdateRecipeResponse(id=updated_id)
except HTTPStatusError as e:
if e.response.status_code == 422:
raise McpError(
ErrorData(
code=-1,
message="Recipe name is required and cannot be empty",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to update recipe {recipe_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
) -> DeleteRecipeResponse:
"""Delete a recipe permanently"""
logger.info("Deleting recipe %s", recipe_id)
client = get_client(ctx)
try:
message = await client.cookbook.delete_recipe(recipe_id)
return DeleteRecipeResponse(
status_code=200,
message=message,
deleted_id=recipe_id,
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to delete recipe {recipe_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_search_recipes(
query: str, ctx: Context
) -> SearchRecipesResponse:
"""Search for recipes by keywords, tags, and categories"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.search_recipes(query)
recipes = [RecipeStub(**r) for r in recipes_data]
return SearchRecipesResponse(
recipes=recipes, query=query, total_found=len(recipes)
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to search recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message="Search failed: server error",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Search failed: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
Note: A category name of '*' indicates recipes with no category."""
client = get_client(ctx)
try:
categories_data = await client.cookbook.list_categories()
categories = [Category(**c) for c in categories_data]
return ListCategoriesResponse(categories=categories)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list categories",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list categories: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
) -> ListRecipesResponse:
"""Get all recipes in a specific category.
Use '_' as the category name to get recipes with no category."""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.get_recipes_in_category(category)
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to access recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message=f"Could not find category '{category}'",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get recipes in category: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
client = get_client(ctx)
try:
keywords_data = await client.cookbook.list_keywords()
keywords = [Keyword(**k) for k in keywords_data]
return ListKeywordsResponse(keywords=keywords)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list keywords",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list keywords: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
) -> ListRecipesResponse:
"""Get all recipes that have specific keywords/tags"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to access recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message="Failed to get recipes with keywords: server error",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get recipes with keywords: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_set_config(
folder: str | None = None,
update_interval: int | None = None,
print_image: bool | None = None,
ctx: Context = None,
) -> ReindexResponse:
"""Set Cookbook app configuration.
Args:
folder: Recipe folder path in user's files
update_interval: Automatic rescan interval in minutes
print_image: Whether to print images with recipes"""
client = get_client(ctx)
config_data = {}
if folder is not None:
config_data["folder"] = folder
if update_interval is not None:
config_data["update_interval"] = update_interval
if print_image is not None:
config_data["print_image"] = print_image
try:
result = await client.cookbook.set_config(config_data)
return ReindexResponse(status_code=200, message=str(result))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to set configuration",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to set configuration: server error ({e.response.status_code})",
)
)
@mcp.tool()
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
This rebuilds the search index and should be used after manual file changes."""
client = get_client(ctx)
try:
message = await client.cookbook.reindex()
return ReindexResponse(status_code=200, message=message)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to reindex",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to reindex: server error ({e.response.status_code})",
)
)
+48 -1
View File
@@ -1,6 +1,6 @@
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
@@ -61,6 +61,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"))
@@ -92,6 +99,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(
@@ -146,6 +157,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"))
@@ -192,6 +209,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"))
@@ -238,6 +262,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(
@@ -265,6 +293,12 @@ 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 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"))
@@ -295,6 +329,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(
@@ -330,6 +371,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"))
+133
View File
@@ -0,0 +1,133 @@
"""MCP tools for Nextcloud file/folder sharing operations."""
import json
from nextcloud_mcp_server.context import get_client
from mcp.server.fastmcp import Context, FastMCP
def configure_sharing_tools(mcp: FastMCP):
"""Configure sharing-related MCP tools.
Args:
mcp: FastMCP server instance
"""
@mcp.tool()
async def nc_share_create(
path: str,
share_with: str,
ctx: Context,
share_type: int = 0,
permissions: int = 1,
) -> str:
"""Create a share for a file or folder in Nextcloud.
Share a file or folder with another user or group. The authenticated user
must own the file/folder being shared.
Args:
path: Path to file/folder to share (relative to your files, e.g., "/document.txt")
share_with: Username (for user share) or group name (for group share)
share_type: Share type - 0 for user (default), 1 for group, 3 for public link
permissions: Share permissions (default: 1 for read-only):
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
JSON string with share information including share ID
"""
client = get_client(ctx)
share_data = await client.sharing.create_share(
path=path,
share_with=share_with,
share_type=share_type,
permissions=permissions,
)
return json.dumps(share_data, indent=2)
@mcp.tool()
async def nc_share_delete(share_id: int, ctx: Context) -> str:
"""Delete a share by its ID.
Remove a share that you created. You must be the owner of the share.
Args:
share_id: The ID of the share to delete
Returns:
JSON string confirming deletion
"""
client = get_client(ctx)
await client.sharing.delete_share(share_id)
return json.dumps(
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
)
@mcp.tool()
async def nc_share_get(share_id: int, ctx: Context) -> str:
"""Get information about a specific share.
Retrieve details about a share by its ID. You must have access to the share
(either as owner or recipient).
Args:
share_id: The ID of the share
Returns:
JSON string with share information
"""
client = get_client(ctx)
share_data = await client.sharing.get_share(share_id)
return json.dumps(share_data, indent=2)
@mcp.tool()
async def nc_share_list(
ctx: Context, path: str | None = None, shared_with_me: bool = False
) -> str:
"""List shares created by you or shared with you.
Args:
path: Optional path to filter shares for a specific file/folder
shared_with_me: If True, list shares that others shared with you.
If False (default), list shares you created.
Returns:
JSON string with list of shares
"""
client = get_client(ctx)
shares = await client.sharing.list_shares(
path=path, shared_with_me=shared_with_me
)
return json.dumps(shares, indent=2)
@mcp.tool()
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
Modify the permissions for a share you created. You must be the owner.
Args:
share_id: The ID of the share to update
permissions: New permissions value:
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
JSON string with updated share information
"""
client = get_client(ctx)
share_data = await client.sharing.update_share(
share_id=share_id, permissions=permissions
)
return json.dumps(share_data, indent=2)
+5 -5
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.13.0"
version = "0.15.0"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
@@ -8,9 +8,9 @@ authors = [
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mcp[cli] (>=1.17,<1.18)",
"mcp[cli] (>=1.18,<1.19)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=11.2.1,<12.0.0)",
"pillow (>=12.0.0,<12.1.0)",
"icalendar (>=6.0.0,<7.0.0)",
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
@@ -22,8 +22,8 @@ asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
log_cli = 1
log_cli_level = "INFO"
log_level = "INFO"
log_cli_level = "WARN"
log_level = "WARN"
markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
+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}")
-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 = await 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.asyncio
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.asyncio
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.asyncio
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
+708 -553
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>
+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")
+20 -1
View File
@@ -52,6 +52,19 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"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 +98,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, (
+2 -2
View File
@@ -38,11 +38,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": ""}
)
+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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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!")
+108
View File
@@ -0,0 +1,108 @@
import pytest
from nextcloud_mcp_server.client import NextcloudClient
@pytest.mark.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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.asyncio
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
+674
View File
@@ -0,0 +1,674 @@
=========================
Instruction set for users
=========================
Add a new user
--------------
Create a new user on the Nextcloud server. Authentication is done by sending a
basic HTTP authentication header.
**Syntax: ocs/v1.php/cloud/users**
* HTTP method: POST
* POST argument: userid - string, the required username for the new user
* POST argument: password - string, the password for the new user, leave empty to send welcome mail
* POST argument: displayName - string, the display name for the new user
* POST argument: email - string, the email for the new user, required if password empty
* POST argument: groups - array, the groups for the new user
* POST argument: subadmin - array, the groups in which the new user is subadmin
* POST argument: quota - string, quota for the new user
* POST argument: language - string, language for the new user
Status codes:
* 101 - invalid argument
* 102 - user already exists
* 103 - cannot create sub-admins for admin group
* 104 - group does not exist
* 105 - insufficient privileges for group
* 106 - no group specified (required for sub-admins)
* 107 - hint exceptions
* 108 - an email address is required, to send a password link to the user.
* 109 - sub-admin group does not exist
* 110 - required email address was not provided
* 111 - could not create non-existing user ID
Example
^^^^^^^
::
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users -d userid="Frank" -d password="frankspassword" -H "OCS-APIRequest: true"
* Creates the user ``Frank`` with password ``frankspassword``
* optionally groups can be specified by one or more ``groups[]`` query parameters:
``URL -d groups[]="admin" -D groups[]="Team1"``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Search/get users
----------------
Retrieves a list of users from the Nextcloud server. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users**
* HTTP method: GET
* url arguments: search - string, optional search string
* url arguments: limit - int, optional limit value
* url arguments: offset - int, optional offset value
Status codes:
* 100 - successful
Example
^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users?search=Frank -H "OCS-APIRequest: true"
* Returns list of users matching the search string.
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data>
<users>
<element>Frank</element>
</users>
</data>
</ocs>
Get data of a single user
-------------------------
Retrieves information about a single user. Authentication is done by sending a
Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}**
* HTTP method: GET
Status codes:
* 100 - successful
Example
^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
* Returns information on the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data>
<enabled>true</enabled>
<id>Frank</id>
<quota>0</quota>
<email>frank@example.org</email>
<displayname>Frank K.</displayname>
<display-name>Frank K.</display-name>
<phone>0123 / 456 789</phone>
<address>Foobar 12, 12345 Town</address>
<website>https://nextcloud.com</website>
<twitter>Nextcloud</twitter>
<groups>
<element>group1</element>
<element>group2</element>
</groups>
</data>
</ocs>
Edit data of a single user
--------------------------
Edits attributes related to a user. Users are able to edit email, displayname
and password; admins can also edit the quota value. Further restrictions may apply,
check the `List of editable data fields`_ endpoint. Authentication
is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}**
* HTTP method: PUT
* PUT argument: key, the field to edit:
+ email
+ quota
+ displayname
+ display (**deprecated** use `displayname` instead)
+ phone
+ address
+ website
+ twitter
+ password
* PUT argument: value, the new value for the field
Status codes:
* 101 - invalid argument
* 107 - password policy (hint exception)
* 112 - Setting the password is not supported by the users backend
* 113 - editing field not allowed / field doesnt exist
Examples
^^^^^^^^
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="email" -d value="franksnewemail@example.org" -H "OCS-APIRequest: true"
* Updates the email address for the user ``Frank``
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="quota" -d value="100MB" -H "OCS-APIRequest: true"
* Updates the quota for the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
.. _editable_field_list:
List of editable data fields
----------------------------
Edits attributes related to a user. Users are able to edit email, displayname
and password; admins can also edit the quota value. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/user/fields**
* HTTP method: GET
Status codes:
* 100 - successful
Examples
^^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/user/fields -H "OCS-APIRequest: true"
* Gets the list of fields
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message>OK</message>
</meta>
<data>
<element>displayname</element>
<element>email</element>
<element>phone</element>
<element>address</element>
<element>website</element>
<element>twitter</element>
</data>
</ocs>
Disable a user
--------------
Disables a user on the Nextcloud server so that the user cannot login anymore.
Authentication is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/disable**
* HTTP method: PUT
Statuscodes:
* 100 - successful
* 101 - failure
Example
^^^^^^^
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/disable -H "OCS-APIRequest: true"
* Disables the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Enable a user
-------------
Enables a user on the Nextcloud server so that the user can login again.
Authentication is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/enable**
* HTTP method: PUT
Statuscodes:
* 100 - successful
* 101 - failure
Example
^^^^^^^
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/enable -H "OCS-APIRequest: true"
* Enables the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Delete a user
-------------
Deletes a user from the Nextcloud server. Authentication is done by sending a
Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}**
* HTTP method: DELETE
Statuscodes:
* 100 - successful
* 101 - failure
Example
^^^^^^^
::
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
* Deletes the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Get user's groups
-----------------
Retrieves a list of groups the specified user is a member of. Authentication is
done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
* HTTP method: GET
Status codes:
* 100 - successful
Example
^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -H "OCS-APIRequest: true"
* Retrieves a list of groups of which ``Frank`` is a member
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data>
<groups>
<element>admin</element>
<element>group1</element>
</groups>
</data>
</ocs>
Add user to group
-----------------
Adds the specified user to the specified group. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
* HTTP method: POST
* POST argument: groupid, string - the group to add the user to
Status codes:
* 100 - successful
* 101 - no group specified
* 102 - group does not exist
* 103 - user does not exist
* 104 - insufficient privileges
* 105 - failed to add user to group
Example
^^^^^^^
::
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
* Adds the user ``Frank`` to the group ``newgroup``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Remove user from group
----------------------
Removes the specified user from the specified group. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
* HTTP method: DELETE
* DELETE argument: groupid, string - the group to remove the user from
Status codes:
* 100 - successful
* 101 - no group specified
* 102 - group does not exist
* 103 - user does not exist
* 104 - insufficient privileges
* 105 - failed to remove user from group
Example
^^^^^^^
::
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
* Removes the user ``Frank`` from the group ``newgroup``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Promote user to subadmin
------------------------
Makes a user the subadmin of a group. Authentication is done by sending a Basic
HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
* HTTP method: POST
* POST argument: groupid, string - the group of which to make the user a
subadmin
Status codes:
* 100 - successful
* 101 - user does not exist
* 102 - group does not exist
* 103 - unknown failure
Example
^^^^^^^
::
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="group" -H "OCS-APIRequest: true"
* Makes the user ``Frank`` a subadmin of the ``group`` group
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Demote user from subadmin
-------------------------
Removes the subadmin rights for the user specified from the group specified.
Authentication is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
* HTTP method: DELETE
* DELETE argument: groupid, string - the group from which to remove the user's
subadmin rights
Status codes:
* 100 - successful
* 101 - user does not exist
* 102 - user is not a subadmin of the group / group does not exist
* 103 - unknown failure
Example
^^^^^^^
::
$ curl -X DELETE https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="oldgroup" -H "OCS-APIRequest: true"
* Removes ``Frank's`` subadmin rights from the ``oldgroup`` group
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Get user's subadmin groups
--------------------------
Returns the groups in which the user is a subadmin. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
* HTTP method: GET
Status codes:
* 100 - successful
* 101 - user does not exist
* 102 - unknown failure
Example
^^^^^^^
::
$ curl -X GET https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -H "OCS-APIRequest: true"
* Returns the groups of which ``Frank`` is a subadmin
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data>
<element>testgroup</element>
</data>
</ocs>
Resend the welcome email
------------------------
The request to this endpoint triggers the welcome email for this user again.
**Syntax: ocs/v1.php/cloud/users/{userid}/welcome**
* HTTP method: POST
Status codes:
* 100 - successful
* 101 - email address not available
* 102 - sending email failed
Example
^^^^^^^
::
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/welcome -H "OCS-APIRequest: true"
* Sends the welcome email to ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Generated
+87 -84
View File
@@ -593,7 +593,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.17.0"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -608,9 +608,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/79/5724a540df19e192e8606c543cdcf162de8eb435077520cca150f7365ec0/mcp-1.17.0.tar.gz", hash = "sha256:1b57fabf3203240ccc48e39859faf3ae1ccb0b571ff798bbedae800c73c6df90", size = 477951, upload-time = "2025-10-10T12:16:44.519Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/72/3751feae343a5ad07959df713907b5c3fbaed269d697a14b0c449080cf2e/mcp-1.17.0-py3-none-any.whl", hash = "sha256:0660ef275cada7a545af154db3082f176cf1d2681d5e35ae63e014faf0a35d40", size = 167737, upload-time = "2025-10-10T12:16:42.863Z" },
{ url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
]
[package.optional-dependencies]
@@ -630,7 +630,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.13.0"
version = "0.15.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -659,8 +659,8 @@ requires-dist = [
{ name = "click", specifier = ">=8.1.8" },
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.17,<1.18" },
{ name = "pillow", specifier = ">=11.2.1,<12.0.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" },
{ name = "pillow", specifier = ">=12.0.0,<12.1.0" },
{ name = "pydantic", specifier = ">=2.11.4" },
{ name = "pythonvcard4", specifier = ">=0.2.0" },
]
@@ -709,86 +709,89 @@ wheels = [
[[package]]
name = "pillow"
version = "11.3.0"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" },
{ url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" },
{ url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" },
{ url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" },
{ url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" },
{ url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" },
{ url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" },
{ url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" },
{ url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" },
{ url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" },
{ url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" },
{ url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
{ url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
{ url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
{ url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
{ url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
{ url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
{ url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
{ url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
{ url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
{ url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
{ url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
{ url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
{ url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
{ url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" },
{ url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" },
{ url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" },
{ url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
]
[[package]]