Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b50fa9b824 |
+1
-3
@@ -4,6 +4,4 @@ __pycache__/
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Generated by pytest used to login users
|
||||
.nextcloud_oauth_shared_test_client.json
|
||||
.nextcloud_oauth_test_client.json
|
||||
|
||||
@@ -1,39 +1,3 @@
|
||||
## 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
|
||||
|
||||
@@ -38,21 +38,13 @@ mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
# Docker development environment with Nextcloud instance
|
||||
docker-compose up
|
||||
|
||||
# After code changes, rebuild and restart the appropriate MCP server container:
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
# After code changes, rebuild and restart only the MCP server container
|
||||
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
|
||||
@@ -104,23 +96,18 @@ Each Nextcloud app has a corresponding server module that:
|
||||
|
||||
### Testing Structure
|
||||
|
||||
- **Integration tests** in `tests/integration/` and `tests/client/`, `tests/server/` - Test real Nextcloud API interactions
|
||||
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
|
||||
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- Tests are marked with `@pytest.mark.integration` for selective running
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
|
||||
|
||||
#### Testing Best Practices
|
||||
- **MANDATORY: Always run tests after implementing features or fixing bugs**
|
||||
- Run tests to completion before considering any task complete
|
||||
- If tests require modifications to pass, ask for permission before proceeding
|
||||
- **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 `docker-compose up --build -d mcp` to rebuild MCP container after code changes
|
||||
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
|
||||
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
|
||||
- `temporary_note` - Creates and cleans up test notes automatically
|
||||
- `temporary_addressbook` - Creates and cleans up test address books
|
||||
@@ -128,48 +115,51 @@ 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 use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows:
|
||||
|
||||
**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
|
||||
**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
|
||||
- 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`)
|
||||
- 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
|
||||
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
||||
# Run specific Playwright tests with visible browser for debugging
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox --headed -v
|
||||
|
||||
# Run specific tests with visible browser for debugging
|
||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/integration/test_oauth.py -v
|
||||
```
|
||||
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/server/test_oauth*.py -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
|
||||
```
|
||||
|
||||
**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
|
||||
**Test Environment Setup:**
|
||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
||||
- OAuth server runs on port 8001 (regular MCP on 8000)
|
||||
- Both flows register OAuth clients dynamically using Nextcloud's OIDC provider
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
**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
|
||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||
|
||||
### Configuration Files
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.3-python3.11-alpine@sha256:c5c8e9241027c384aa5e0d0368a6fd013945a23b7a5f25c754ed55ea7ef64f92
|
||||
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
|
||||
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
|
||||
@@ -18,7 +15,6 @@ 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. |
|
||||
@@ -30,16 +26,8 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
| Mode | Security | Best For |
|
||||
|------|----------|----------|
|
||||
| **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** ✅ | 🔒 High | Production, multi-user deployments |
|
||||
| **Basic Auth** ⚠️ | Lower | Development, testing |
|
||||
|
||||
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
|
||||
|
||||
@@ -70,35 +58,29 @@ Create a `.env` file:
|
||||
cp env.sample .env
|
||||
```
|
||||
|
||||
**For Basic Auth (recommended for most users):**
|
||||
**For OAuth (recommended):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
**For Basic Auth:**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
```
|
||||
|
||||
**For OAuth (experimental - requires patches):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for all options.
|
||||
|
||||
### 3. Set Up Authentication
|
||||
|
||||
**Basic Auth Setup (recommended):**
|
||||
1. Create an app password in Nextcloud (Settings → Security → Devices & sessions)
|
||||
2. Add credentials to `.env` file
|
||||
3. Start the server
|
||||
|
||||
**OAuth Setup (experimental):**
|
||||
**OAuth Setup (recommended):**
|
||||
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
|
||||
2. Enable dynamic client registration
|
||||
3. Configure Bearer token validation
|
||||
4. 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.
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment.
|
||||
|
||||
### 4. Run the Server
|
||||
|
||||
@@ -106,15 +88,12 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start with Basic Auth (default)
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Or start with OAuth (experimental - requires patches)
|
||||
# Start the server
|
||||
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
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
```
|
||||
|
||||
The server starts on `http://127.0.0.1:8000` by default.
|
||||
@@ -141,15 +120,12 @@ Or connect from:
|
||||
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
|
||||
- **[Running the Server](docs/running.md)** - Start and manage the server
|
||||
|
||||
### Architecture
|
||||
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent
|
||||
|
||||
### OAuth Documentation (Experimental)
|
||||
### OAuth Documentation
|
||||
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Production deployment
|
||||
- **[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
|
||||
@@ -158,7 +134,6 @@ 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)
|
||||
@@ -170,7 +145,6 @@ 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
|
||||
@@ -179,7 +153,6 @@ 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...
|
||||
@@ -194,12 +167,6 @@ 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"
|
||||
@@ -247,8 +214,7 @@ Contributions are welcome!
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
This project takes security seriously:
|
||||
- OAuth2/OIDC support (experimental - requires upstream patches)
|
||||
- Basic Auth with app-specific passwords (recommended)
|
||||
- OAuth2/OIDC support for secure authentication
|
||||
- No credential storage with OAuth mode
|
||||
- Per-user access tokens
|
||||
- Regular security assessments
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable cookbook
|
||||
@@ -17,6 +17,11 @@ 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
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ services:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
||||
# No USERNAME/PASSWORD - will use OAuth
|
||||
volumes:
|
||||
|
||||
@@ -1,698 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,189 +0,0 @@
|
||||
# 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).
|
||||
@@ -217,12 +217,11 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
|
||||
**How it works**:
|
||||
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
||||
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||
2. Calls `/apps/oidc/register` to register new client
|
||||
3. Saves credentials to `.nextcloud_oauth_client.json`
|
||||
4. Reuses these credentials on subsequent startups
|
||||
5. Re-registers only if credentials are missing or expired
|
||||
4. Re-registers if credentials expire
|
||||
|
||||
**Best for**: Development, testing, quick deployments
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
|
||||
### Pre-configured Client
|
||||
|
||||
|
||||
+7
-7
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
|
||||
|
||||
### Mode A: Automatic Registration (Dynamic Client Registration)
|
||||
|
||||
**Best for**: Development, testing, quick deployments
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
|
||||
**How it works**:
|
||||
- MCP server automatically registers an OAuth client on first startup
|
||||
- MCP server automatically registers OAuth client at startup
|
||||
- Uses Nextcloud's dynamic client registration endpoint
|
||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
||||
- Reuses stored credentials on subsequent restarts
|
||||
- Re-registers automatically if credentials expire
|
||||
|
||||
**Pros**:
|
||||
- Zero configuration required
|
||||
- Quick setup
|
||||
- Automatic credential management
|
||||
- No manual client management
|
||||
|
||||
**Cons**:
|
||||
- Clients expire (default: 1 hour, configurable)
|
||||
- Must have dynamic client registration enabled on Nextcloud
|
||||
- Must re-register on restart if expired
|
||||
- Not ideal for long-running production
|
||||
|
||||
**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 an OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server via environment variables
|
||||
- You manually register OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server
|
||||
- Credentials don't expire
|
||||
|
||||
**Pros**:
|
||||
|
||||
@@ -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
|
||||
- Quick deployments
|
||||
- Short-lived deployments
|
||||
|
||||
For **production deployments**, consider:
|
||||
1. Pre-registering OAuth client manually
|
||||
2. Using dedicated client credentials that don't expire
|
||||
For **production deployments**, you should:
|
||||
1. Pre-register OAuth clients manually
|
||||
2. Use dedicated client credentials
|
||||
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
|
||||
|
||||
---
|
||||
|
||||
@@ -19,10 +19,8 @@ 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,
|
||||
)
|
||||
@@ -377,10 +375,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -446,9 +442,7 @@ 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", "cookbook", "deck"]
|
||||
),
|
||||
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||
)
|
||||
@click.option(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Dynamic client registration for Nextcloud OIDC."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -205,12 +206,73 @@ 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.
|
||||
@@ -218,8 +280,9 @@ 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 (no stored credentials or expired)
|
||||
4. Saves the new client credentials
|
||||
3. Registers a new client if needed
|
||||
4. Waits for the client to propagate (if newly registered)
|
||||
5. Saves the new client credentials
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
@@ -227,6 +290,8 @@ async def load_or_register_client(
|
||||
storage_path: Path to store client credentials
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
force_register: Force registration even if valid credentials exist
|
||||
wait_for_propagation: Wait for newly registered clients to propagate (default: True)
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -237,10 +302,11 @@ async def load_or_register_client(
|
||||
"""
|
||||
storage_path = Path(storage_path)
|
||||
|
||||
# Try to load existing client
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
# 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
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
@@ -251,6 +317,15 @@ 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)
|
||||
|
||||
|
||||
@@ -14,14 +14,10 @@ from httpx import (
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
from .webdav import WebDAVClient
|
||||
from .users import UsersClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,11 +70,7 @@ 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()
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"""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()
|
||||
@@ -99,7 +99,7 @@ class DeckClient(BaseNextcloudClient):
|
||||
permission_edit: bool,
|
||||
permission_share: bool,
|
||||
permission_manage: bool,
|
||||
) -> DeckACL:
|
||||
) -> List[DeckACL]:
|
||||
json_data = {
|
||||
"type": type,
|
||||
"participant": participant,
|
||||
@@ -107,14 +107,10 @@ 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,
|
||||
headers=headers,
|
||||
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/acl", json=json_data
|
||||
)
|
||||
return DeckACL(**response.json())
|
||||
return [DeckACL(**acl) for acl in response.json()]
|
||||
|
||||
async def update_acl_rule(
|
||||
self,
|
||||
@@ -131,20 +127,13 @@ 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,
|
||||
headers=headers,
|
||||
"PUT", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}", json=json_data
|
||||
)
|
||||
|
||||
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
headers=headers,
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}"
|
||||
)
|
||||
|
||||
async def clone_board(
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1,208 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,222 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,220 +0,0 @@
|
||||
"""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
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
@@ -1,19 +1,15 @@
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
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 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})",
|
||||
)
|
||||
)
|
||||
@@ -1,133 +0,0 @@
|
||||
"""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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.15.0"
|
||||
version = "0.13.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.18,<1.19)",
|
||||
"mcp[cli] (>=1.17,<1.18)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=12.0.0,<12.1.0)",
|
||||
"pillow (>=11.2.1,<12.0.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 = "WARN"
|
||||
log_level = "WARN"
|
||||
log_cli_level = "INFO"
|
||||
log_level = "INFO"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
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_recipe_server: str
|
||||
):
|
||||
"""Test importing a recipe from a URL.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses a local test server to provide reliable, controlled test data.
|
||||
"""
|
||||
# Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it
|
||||
# The test_recipe_server runs on the host, but Nextcloud runs in Docker
|
||||
# On Linux, 172.17.0.1 is the default Docker bridge gateway
|
||||
# On Mac/Windows, try host.docker.internal first
|
||||
import platform
|
||||
|
||||
if platform.system() == "Linux":
|
||||
docker_host = "172.17.0.1"
|
||||
else:
|
||||
docker_host = "host.docker.internal"
|
||||
|
||||
docker_accessible_url = test_recipe_server.replace("localhost", docker_host)
|
||||
test_url = f"{docker_accessible_url}/black-pepper-tofu"
|
||||
|
||||
logger.info(f"Importing recipe from local test URL (Docker-accessible): {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 local test URL: {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}")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""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}")
|
||||
@@ -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):
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
|
||||
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
capabilities = await nc_oauth_client_playwright.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (Playwright) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client.notes.get_all_notes()
|
||||
notes = await nc_oauth_client_playwright.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
"""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
|
||||
+515
-744
File diff suppressed because it is too large
Load Diff
Vendored
-133
@@ -1,133 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,564 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
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_recipe_server: str,
|
||||
):
|
||||
"""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 a local test server to provide reliable, controlled test data.
|
||||
"""
|
||||
# Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it
|
||||
if platform.system() == "Linux":
|
||||
docker_host = "172.17.0.1"
|
||||
else:
|
||||
docker_host = "host.docker.internal"
|
||||
|
||||
docker_accessible_url = test_recipe_server.replace("localhost", docker_host)
|
||||
test_url = f"{docker_accessible_url}/black-pepper-tofu"
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Import recipe via MCP
|
||||
logger.info(
|
||||
f"Importing recipe from local test URL via MCP (Docker-accessible): {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']}")
|
||||
@@ -1,569 +0,0 @@
|
||||
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")
|
||||
@@ -52,19 +52,6 @@ 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:
|
||||
@@ -98,13 +85,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
expected_resources = [
|
||||
"nc://capabilities",
|
||||
"notes://settings",
|
||||
"nc://Deck/boards",
|
||||
"cookbook://version",
|
||||
"cookbook://config",
|
||||
]
|
||||
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
|
||||
|
||||
for expected_resource in expected_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
|
||||
@@ -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):
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
result = await nc_mcp_oauth_client_playwright.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,425 +0,0 @@
|
||||
"""
|
||||
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}
|
||||
)
|
||||
@@ -1,260 +0,0 @@
|
||||
"""
|
||||
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!")
|
||||
@@ -1,108 +0,0 @@
|
||||
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
@@ -1,674 +0,0 @@
|
||||
=========================
|
||||
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 doesn’t 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>
|
||||
@@ -593,7 +593,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.18.0"
|
||||
version = "1.17.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/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -630,7 +630,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.15.0"
|
||||
version = "0.13.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.18,<1.19" },
|
||||
{ name = "pillow", specifier = ">=12.0.0,<12.1.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.17,<1.18" },
|
||||
{ name = "pillow", specifier = ">=11.2.1,<12.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.4" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
]
|
||||
@@ -709,89 +709,86 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.0.0"
|
||||
version = "11.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user