Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1207770ca | |||
| d694243723 | |||
| 8e7191e0ea | |||
| dbcf9d93ca | |||
| 27519d0f62 | |||
| 2999d4b65e | |||
| 0fd32ecd34 | |||
| 604a2065cb | |||
| 0aeef1b87e | |||
| b65f10ed8e | |||
| 038fcddd48 | |||
| 394b27ee4a | |||
| 9de59db718 | |||
| 6734de8389 | |||
| 3cb31d07f1 | |||
| 16b9123af3 | |||
| 51d1f075f5 | |||
| e0a68d47a5 | |||
| 832cb51dd3 | |||
| f6256c10db | |||
| 7b2002c1b5 | |||
| d150cf2e72 | |||
| 3921d9b982 | |||
| 9e4c20a4b1 | |||
| f26bca13f1 | |||
| 46c6f2f294 | |||
| 3ad9198f36 | |||
| dafac734e6 | |||
| 97bbc18121 |
@@ -1,3 +1,27 @@
|
||||
## 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
|
||||
|
||||
@@ -132,61 +132,45 @@ Each Nextcloud app has a corresponding server module that:
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows:
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
**Automated Testing (Default - Recommended for CI/CD):**
|
||||
- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default
|
||||
- Uses Playwright headless browser automation to complete OAuth flow programmatically
|
||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client (matching MCP server behavior)
|
||||
- Single `client_id`/`client_secret` pair is registered and reused for all test users
|
||||
- Stored in `.nextcloud_oauth_shared_test_client.json` with `force_register=False` for reuse
|
||||
- Reduces OAuth client registrations and matches production MCP server architecture
|
||||
- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright`
|
||||
- Multi-user fixtures: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||
- All use `shared_oauth_client_credentials` fixture for consistent client credentials
|
||||
- Each user gets unique access tokens via same OAuth client (like multiple users using the MCP server)
|
||||
- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
**OAuth Testing Setup:**
|
||||
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
|
||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
|
||||
- Stored in `.nextcloud_oauth_shared_test_client.json`
|
||||
- Matches production MCP server behavior
|
||||
- Each user gets their own unique access token
|
||||
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
|
||||
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
|
||||
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
|
||||
- Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- Install browsers: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
- Example:
|
||||
```bash
|
||||
# Run all OAuth tests with automated Playwright flow using Firefox
|
||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
||||
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
|
||||
# Run specific Playwright tests with visible browser for debugging
|
||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
||||
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/server/test_oauth*.py -v
|
||||
```
|
||||
# Run specific tests with visible browser for debugging
|
||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
||||
|
||||
**Interactive Testing (Manual browser login):**
|
||||
- Opens system browser and waits for manual login/authorization
|
||||
- Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive`
|
||||
- Requires: User to complete browser-based login when prompted
|
||||
- Useful for: Debugging OAuth flows, testing with 2FA, local development
|
||||
- **Automatically skipped in GitHub Actions CI** - Interactive fixtures check for `GITHUB_ACTIONS` environment variable
|
||||
- Example:
|
||||
```bash
|
||||
# Run OAuth tests with interactive flow (will open browser and wait for manual login)
|
||||
uv run pytest tests/client/test_oauth_interactive.py -v
|
||||
```
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/server/test_oauth*.py -v
|
||||
```
|
||||
|
||||
**Test Environment Setup:**
|
||||
**Test Environment:**
|
||||
- **Two MCP server containers are available:**
|
||||
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
|
||||
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
- Shared OAuth client is registered once and reused across test runs
|
||||
- Client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
||||
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
||||
|
||||
**CI/CD Considerations:**
|
||||
- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set
|
||||
- Automated Playwright tests will run in CI/CD environments
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||
- Shared client approach reduces test time and API calls to Nextcloud
|
||||
|
||||
### Configuration Files
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
|
||||
FROM ghcr.io/astral-sh/uv:0.9.3-python3.11-alpine@sha256:c5c8e9241027c384aa5e0d0368a6fd013945a23b7a5f25c754ed55ea7ef64f92
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
|
||||
|
||||
> [!NOTE]
|
||||
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
@@ -15,6 +18,7 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models l
|
||||
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
|
||||
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
|
||||
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
|
||||
| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. |
|
||||
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
|
||||
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
|
||||
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
|
||||
@@ -26,8 +30,16 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
| Mode | Security | Best For |
|
||||
|------|----------|----------|
|
||||
| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments |
|
||||
| **Basic Auth** ⚠️ | Lower | Development, testing |
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) |
|
||||
| **Basic Auth** ✅ | Lower | Development, testing, production |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically:
|
||||
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
|
||||
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
|
||||
> - **Production use**: Wait for upstream patches to be merged into official releases
|
||||
>
|
||||
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
|
||||
|
||||
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
|
||||
|
||||
@@ -58,29 +70,35 @@ Create a `.env` file:
|
||||
cp env.sample .env
|
||||
```
|
||||
|
||||
**For OAuth (recommended):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
**For Basic Auth:**
|
||||
**For Basic Auth (recommended for most users):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
```
|
||||
|
||||
**For OAuth (experimental - requires patches):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for all options.
|
||||
|
||||
### 3. Set Up Authentication
|
||||
|
||||
**OAuth Setup (recommended):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
||||
2. Enable dynamic client registration
|
||||
3. Configure Bearer token validation
|
||||
4. Start the server
|
||||
**Basic Auth Setup (recommended):**
|
||||
1. Create an app password in Nextcloud (Settings → Security → Devices & sessions)
|
||||
2. Add credentials to `.env` file
|
||||
3. Start the server
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment.
|
||||
**OAuth Setup (experimental):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
||||
2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||
3. Enable dynamic client registration
|
||||
4. Configure Bearer token validation
|
||||
5. Start the server
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
|
||||
|
||||
### 4. Run the Server
|
||||
|
||||
@@ -88,12 +106,15 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start the server
|
||||
# Start with Basic Auth (default)
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Or start with OAuth (experimental - requires patches)
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# Or with Docker
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
The server starts on `http://127.0.0.1:8000` by default.
|
||||
@@ -120,12 +141,15 @@ Or connect from:
|
||||
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
|
||||
- **[Running the Server](docs/running.md)** - Start and manage the server
|
||||
|
||||
### OAuth Documentation
|
||||
### Architecture
|
||||
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent
|
||||
|
||||
### OAuth Documentation (Experimental)
|
||||
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Production deployment
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions
|
||||
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works
|
||||
- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues
|
||||
- **[Upstream Status](docs/oauth-upstream-status.md)** - Required patches and PRs
|
||||
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
|
||||
|
||||
### Reference
|
||||
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
||||
@@ -134,6 +158,7 @@ Or connect from:
|
||||
- [Notes API](docs/notes.md)
|
||||
- [Calendar (CalDAV)](docs/calendar.md)
|
||||
- [Contacts (CardDAV)](docs/contacts.md)
|
||||
- [Cookbook](docs/cookbook.md)
|
||||
- [Deck](docs/deck.md)
|
||||
- [Tables](docs/table.md)
|
||||
- [WebDAV](docs/webdav.md)
|
||||
@@ -145,6 +170,7 @@ The server exposes Nextcloud functionality through MCP tools (for actions) and r
|
||||
### Tools
|
||||
Tools enable AI assistants to perform actions:
|
||||
- `nc_notes_create_note` - Create a new note
|
||||
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
||||
- `deck_create_card` - Create a Deck card
|
||||
- `nc_calendar_create_event` - Create a calendar event
|
||||
- `nc_contacts_create_contact` - Create a contact
|
||||
@@ -153,6 +179,7 @@ Tools enable AI assistants to perform actions:
|
||||
### Resources
|
||||
Resources provide read-only access to Nextcloud data:
|
||||
- `nc://capabilities` - Server capabilities
|
||||
- `cookbook://version` - Cookbook app version info
|
||||
- `nc://Deck/boards/{board_id}` - Deck board data
|
||||
- `notes://settings` - Notes app settings
|
||||
- And more...
|
||||
@@ -167,6 +194,12 @@ AI: "Create a note called 'Meeting Notes' with today's agenda"
|
||||
→ Uses nc_notes_create_note tool
|
||||
```
|
||||
|
||||
### Manage Recipes
|
||||
```
|
||||
AI: "Import the recipe from this URL: https://www.example.com/recipe/chocolate-cake"
|
||||
→ Uses nc_cookbook_import_recipe tool to extract schema.org metadata
|
||||
```
|
||||
|
||||
### Manage Calendar
|
||||
```
|
||||
AI: "Schedule a team meeting for next Tuesday at 2pm"
|
||||
@@ -214,7 +247,8 @@ Contributions are welcome!
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
This project takes security seriously:
|
||||
- OAuth2/OIDC support for secure authentication
|
||||
- OAuth2/OIDC support (experimental - requires upstream patches)
|
||||
- Basic Auth with app-specific passwords (recommended)
|
||||
- No credential storage with OAuth mode
|
||||
- Per-user access tokens
|
||||
- Regular security assessments
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable cookbook
|
||||
@@ -40,6 +40,13 @@ services:
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http"]
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
# MCP Server Comparison: Nextcloud MCP Server vs Context Agent
|
||||
|
||||
This document compares the two MCP server implementations in the Nextcloud ecosystem:
|
||||
|
||||
1. **Nextcloud MCP Server** (this project) - Standalone MCP server for external access to Nextcloud
|
||||
2. **Context Agent MCP Server** - MCP server embedded within Nextcloud as an External App
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Both projects expose Nextcloud functionality via the Model Context Protocol (MCP), but serve different purposes and audiences:
|
||||
|
||||
- **Nextcloud MCP Server**: Brings Nextcloud OUT to external MCP clients (Claude Code, etc.)
|
||||
- **Context Agent**: Brings external MCP servers IN to Nextcloud's AI Assistant
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph External["External Clients"]
|
||||
CC[Claude Code]
|
||||
IDE[IDEs with MCP]
|
||||
APP[Other MCP Clients]
|
||||
end
|
||||
|
||||
subgraph NMCP["Nextcloud MCP Server<br/>(This Project)"]
|
||||
NMCP_Server[FastMCP Server]
|
||||
NMCP_Client[HTTP Clients]
|
||||
NMCP_Auth[OAuth/BasicAuth]
|
||||
end
|
||||
|
||||
subgraph NC["Nextcloud Instance"]
|
||||
subgraph CA["Context Agent ExApp"]
|
||||
CA_Agent[LangGraph Agent]
|
||||
CA_MCP[MCP Server /mcp]
|
||||
CA_Tools[Tool Loader]
|
||||
end
|
||||
|
||||
NC_Apps[Nextcloud Apps<br/>Notes, Calendar, Files, etc.]
|
||||
NC_Assistant[Assistant App]
|
||||
end
|
||||
|
||||
subgraph ExtMCP["External MCP Servers"]
|
||||
Weather[Weather MCP]
|
||||
Other[Other Services]
|
||||
end
|
||||
|
||||
%% External clients connect to standalone MCP server
|
||||
CC --> NMCP_Server
|
||||
IDE --> NMCP_Server
|
||||
APP --> NMCP_Server
|
||||
|
||||
%% Standalone MCP server talks to Nextcloud over HTTP
|
||||
NMCP_Server --> NMCP_Auth
|
||||
NMCP_Auth --> NMCP_Client
|
||||
NMCP_Client -->|HTTP/HTTPS| NC_Apps
|
||||
|
||||
%% Context Agent is inside Nextcloud
|
||||
CA_Agent --> CA_Tools
|
||||
CA_Tools --> NC_Apps
|
||||
CA_MCP -->|Exposes to| NC_Assistant
|
||||
NC_Assistant -->|User requests| CA_Agent
|
||||
|
||||
%% Context Agent can consume external MCP servers
|
||||
CA_Tools -->|Consumes| ExtMCP
|
||||
|
||||
%% Context Agent could consume Nextcloud MCP Server
|
||||
CA_Tools -.->|Could consume| NMCP_Server
|
||||
|
||||
classDef external fill:#e1f5ff
|
||||
classDef standalone fill:#fff4e1
|
||||
classDef internal fill:#e8f5e9
|
||||
|
||||
class CC,IDE,APP external
|
||||
class NMCP_Server,NMCP_Client,NMCP_Auth standalone
|
||||
class CA_Agent,CA_MCP,CA_Tools,NC_Apps,NC_Assistant internal
|
||||
```
|
||||
|
||||
## Deployment Models
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Deploy1["Nextcloud MCP Server Deployment"]
|
||||
direction TB
|
||||
D1[Docker Container]
|
||||
D2[Cloud VM]
|
||||
D3[Local Machine]
|
||||
D4[Kubernetes Pod]
|
||||
end
|
||||
|
||||
subgraph Deploy2["Context Agent Deployment"]
|
||||
direction TB
|
||||
NC[Nextcloud Instance<br/>with AppAPI]
|
||||
ExApp[External App Container<br/>Managed by Nextcloud]
|
||||
end
|
||||
|
||||
Deploy1 -.->|HTTP/HTTPS| NC
|
||||
ExApp -->|Integrated| NC
|
||||
|
||||
classDef deploy fill:#fff4e1
|
||||
classDef integrated fill:#e8f5e9
|
||||
|
||||
class D1,D2,D3,D4 deploy
|
||||
class NC,ExApp integrated
|
||||
```
|
||||
|
||||
### Nextcloud MCP Server
|
||||
- **Location**: Runs anywhere with network access to Nextcloud
|
||||
- **Deployment**: Docker, VM, local machine, Kubernetes
|
||||
- **Connection**: HTTP/HTTPS to Nextcloud APIs
|
||||
- **Independence**: Fully standalone service
|
||||
|
||||
### Context Agent
|
||||
- **Location**: Runs inside Nextcloud as External App
|
||||
- **Deployment**: Managed by Nextcloud AppAPI
|
||||
- **Connection**: Native nc-py-api integration
|
||||
- **Integration**: Deep Nextcloud integration
|
||||
|
||||
## Authentication Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph NMCP_Auth["Nextcloud MCP Server Authentication"]
|
||||
direction TB
|
||||
Client1[MCP Client]
|
||||
|
||||
subgraph BasicAuth["BasicAuth Mode"]
|
||||
BA_Shared[Shared NextcloudClient]
|
||||
BA_Creds[Username + Password]
|
||||
end
|
||||
|
||||
subgraph OAuth["OAuth Mode"]
|
||||
OAuth_Token[OAuth Token]
|
||||
OAuth_Verify[Token Verifier]
|
||||
OAuth_OIDC[OIDC Discovery]
|
||||
OAuth_Client[Per-Request Client]
|
||||
end
|
||||
|
||||
Client1 -->|Basic Auth| BasicAuth
|
||||
Client1 -->|Bearer Token| OAuth
|
||||
BA_Creds --> BA_Shared
|
||||
OAuth_Token --> OAuth_Verify
|
||||
OAuth_OIDC --> OAuth_Verify
|
||||
OAuth_Verify --> OAuth_Client
|
||||
end
|
||||
|
||||
subgraph CA_Auth["Context Agent Authentication"]
|
||||
direction TB
|
||||
Client2[MCP Client]
|
||||
CA_Header[Authorization Header]
|
||||
CA_OCS[OCS API Validation]
|
||||
CA_User[User Context]
|
||||
CA_NC[nc-py-api Client]
|
||||
|
||||
Client2 --> CA_Header
|
||||
CA_Header --> CA_OCS
|
||||
CA_OCS -->|Extract user_id| CA_User
|
||||
CA_User -->|nc.set_user| CA_NC
|
||||
end
|
||||
|
||||
classDef auth fill:#fff4e1
|
||||
classDef user fill:#e1f5ff
|
||||
|
||||
class BasicAuth,OAuth auth
|
||||
class CA_User user
|
||||
```
|
||||
|
||||
## Tool Registration & Loading
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Startup
|
||||
participant NMCP as Nextcloud MCP<br/>Server
|
||||
participant CA as Context Agent
|
||||
participant Request as Client Request
|
||||
|
||||
Note over Startup,NMCP: Nextcloud MCP Server (Static)
|
||||
Startup->>NMCP: Server starts
|
||||
NMCP->>NMCP: configure_notes_tools(mcp)
|
||||
NMCP->>NMCP: configure_calendar_tools(mcp)
|
||||
NMCP->>NMCP: configure_contacts_tools(mcp)
|
||||
Note over NMCP: Tools registered once<br/>at startup
|
||||
Request->>NMCP: Call tool
|
||||
NMCP->>NMCP: Use pre-registered tool
|
||||
|
||||
Note over Startup,CA: Context Agent (Dynamic)
|
||||
Startup->>CA: Server starts
|
||||
CA->>CA: Install ToolListMiddleware
|
||||
Request->>CA: List tools (or 60s elapsed)
|
||||
CA->>CA: get_tools(nc)
|
||||
CA->>CA: Import all_tools/*.py
|
||||
CA->>CA: Call module.get_tools(nc)
|
||||
CA->>CA: Regenerate tool functions
|
||||
Note over CA: Tools refreshed every 60s<br/>or on demand
|
||||
Request->>CA: Call tool
|
||||
CA->>CA: Regenerate with fresh nc
|
||||
```
|
||||
|
||||
## Tool Definition Patterns
|
||||
|
||||
### Nextcloud MCP Server
|
||||
|
||||
```python
|
||||
# Static registration at startup
|
||||
def configure_notes_tools(mcp: FastMCP):
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(
|
||||
title: str,
|
||||
content: str,
|
||||
category: str,
|
||||
ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note"""
|
||||
client = get_client(ctx) # Auto-detects auth mode
|
||||
note_data = await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category
|
||||
)
|
||||
return CreateNoteResponse(
|
||||
id=note_data["id"],
|
||||
title=note_data["title"],
|
||||
etag=note_data["etag"]
|
||||
)
|
||||
|
||||
# Resources for structured data access
|
||||
@mcp.resource("nc://Notes/{note_id}")
|
||||
async def nc_get_note_resource(note_id: int):
|
||||
"""Get user note using note id"""
|
||||
ctx = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- Native FastMCP `@mcp.tool()` decorator
|
||||
- Pydantic models for type safety
|
||||
- MCP Resources support
|
||||
- Comprehensive error handling with McpError
|
||||
- Context-based client resolution
|
||||
|
||||
### Context Agent
|
||||
|
||||
```python
|
||||
# Dynamic loading at runtime
|
||||
async def get_tools(nc: Nextcloud):
|
||||
@tool
|
||||
@safe_tool
|
||||
def list_calendars():
|
||||
"""List all existing calendars by name"""
|
||||
principal = nc.cal.principal()
|
||||
calendars = principal.calendars()
|
||||
return ", ".join([cal.name for cal in calendars])
|
||||
|
||||
@tool
|
||||
@dangerous_tool
|
||||
def schedule_event(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
description: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
attendees: list[str] | None,
|
||||
start_time: str | None,
|
||||
end_time: str | None
|
||||
):
|
||||
"""Create a new event or meeting in a calendar"""
|
||||
# Parse dates and times
|
||||
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
# ... event creation logic
|
||||
principal = nc.cal.principal()
|
||||
calendar = {cal.name: cal for cal in calendars}[calendar_name]
|
||||
calendar.add_event(str(c))
|
||||
return True
|
||||
|
||||
return [list_calendars, schedule_event, ...]
|
||||
|
||||
def get_category_name():
|
||||
return "Calendar and Tasks"
|
||||
|
||||
def is_available(nc: Nextcloud):
|
||||
return True # or check capabilities
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- LangChain `@tool` decorator
|
||||
- `@safe_tool` / `@dangerous_tool` decorators
|
||||
- Dynamic tool regeneration with fresh context
|
||||
- Tools returned as list from async function
|
||||
- Availability checking per module
|
||||
|
||||
## Client Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph NMCP_Client["Nextcloud MCP Server Clients"]
|
||||
direction TB
|
||||
NMCP_Main[NextcloudClient]
|
||||
NMCP_Base[BaseNextcloudClient]
|
||||
|
||||
NMCP_Notes[NotesClient]
|
||||
NMCP_Cal[CalendarClient]
|
||||
NMCP_Contacts[ContactsClient]
|
||||
NMCP_Tables[TablesClient]
|
||||
NMCP_WebDAV[WebDAVClient]
|
||||
NMCP_Deck[DeckClient]
|
||||
|
||||
NMCP_Main --> NMCP_Notes
|
||||
NMCP_Main --> NMCP_Cal
|
||||
NMCP_Main --> NMCP_Contacts
|
||||
NMCP_Main --> NMCP_Tables
|
||||
NMCP_Main --> NMCP_WebDAV
|
||||
NMCP_Main --> NMCP_Deck
|
||||
|
||||
NMCP_Notes -.->|extends| NMCP_Base
|
||||
NMCP_Cal -.->|extends| NMCP_Base
|
||||
NMCP_Contacts -.->|extends| NMCP_Base
|
||||
|
||||
NMCP_Base --> HTTPX["httpx.AsyncClient"]
|
||||
NMCP_Base --> Retry["@retry_on_429"]
|
||||
end
|
||||
|
||||
subgraph CA_Client["Context Agent Client"]
|
||||
direction TB
|
||||
CA_NC["nc-py-api<br/>NextcloudApp"]
|
||||
|
||||
CA_NC --> CA_Cal["nc.cal<br/>CalDAV"]
|
||||
CA_NC --> CA_Talk["nc.talk<br/>Talk API"]
|
||||
CA_NC --> CA_OCS["nc.ocs<br/>OCS API"]
|
||||
CA_NC --> CA_Session["nc._session<br/>HTTP Adapter"]
|
||||
end
|
||||
|
||||
HTTPX -->|"HTTP/HTTPS"| NextcloudAPI["Nextcloud APIs"]
|
||||
CA_Session -->|"HTTP/HTTPS"| NextcloudAPI
|
||||
|
||||
classDef custom fill:#fff4e1
|
||||
classDef native fill:#e8f5e9
|
||||
|
||||
class NMCP_Main,NMCP_Base,NMCP_Notes,NMCP_Cal custom
|
||||
class CA_NC,CA_Cal,CA_Talk,CA_OCS native
|
||||
```
|
||||
|
||||
## Functionality Comparison
|
||||
|
||||
### Available Tools & Features
|
||||
|
||||
| Feature Category | Nextcloud MCP Server | Context Agent MCP |
|
||||
|-----------------|---------------------|-------------------|
|
||||
| **Notes** | ✅ Full CRUD, search, attachments (7 tools) | ❌ Not implemented |
|
||||
| **Calendar** | ✅ Full CalDAV (events, recurring, attendees) | ✅ Schedule events, list calendars, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (address books, contacts) | ✅ Find person, current user details (2 tools) |
|
||||
| **Files** | ✅ Full WebDAV (read, write, directories) | ✅ Get content, folder tree, sharing (3 tools) |
|
||||
| **Tables** | ✅ Row CRUD operations | ❌ Not implemented |
|
||||
| **Deck** | ✅ Boards, stacks, cards | ✅ Create board, add card (2 tools) |
|
||||
| **Talk** | ❌ Not implemented | ✅ List/send messages, create conversation (4 tools) |
|
||||
| **Mail** | ❌ Not implemented | ✅ Send email, list mailboxes (2 tools) |
|
||||
| **AI Features** | ❌ Not implemented | ✅ Image gen, audio2text, doc-gen, context_chat (4 tools) |
|
||||
| **Web Search** | ❌ Not implemented | ✅ DuckDuckGo, YouTube search (2 tools) |
|
||||
| **Location** | ❌ Not implemented | ✅ OpenStreetMap, HERE transit, weather (3 tools) |
|
||||
| **OpenProject** | ❌ Not implemented | ✅ Integration (2 tools) |
|
||||
| **MCP Resources** | ✅ notes://, nc:// URIs | ❌ Not supported |
|
||||
| **External MCP** | ❌ Pure server only | ✅ Consumes external MCP servers |
|
||||
| **Sharing** | ✅ Share management API | ❌ Not implemented |
|
||||
| **Capabilities** | ✅ Server info resource | ❌ Not exposed |
|
||||
|
||||
### Tool Count Summary
|
||||
|
||||
- **Nextcloud MCP Server**: ~50+ tools and resources
|
||||
- Deep integration with specific apps
|
||||
- Full CRUD operations
|
||||
- MCP Resources for structured data
|
||||
|
||||
- **Context Agent**: ~28+ tools
|
||||
- Broader feature coverage
|
||||
- Action-oriented (agent tasks)
|
||||
- Can aggregate external MCP servers
|
||||
|
||||
## Tool Safety & Confirmation
|
||||
|
||||
### Context Agent Safety Model
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Request[User Request] --> Agent[LangGraph Agent]
|
||||
Agent --> Model[LLM generates tool calls]
|
||||
Model --> Check{Tool type?}
|
||||
|
||||
Check -->|"@safe_tool"| Execute[Execute immediately]
|
||||
Check -->|"@dangerous_tool"| Queue[Queue for confirmation]
|
||||
|
||||
Queue --> UserNode[Request user confirmation]
|
||||
UserNode -->|Approved| Execute
|
||||
UserNode -->|Denied| Cancel[Cancel with reason]
|
||||
|
||||
Execute --> Result[Return result to agent]
|
||||
Cancel --> Result
|
||||
|
||||
Result --> Agent
|
||||
|
||||
classDef safe fill:#e8f5e9
|
||||
classDef danger fill:#ffe8e8
|
||||
|
||||
class Execute safe
|
||||
class Queue,UserNode,Cancel danger
|
||||
```
|
||||
|
||||
**Safe Tools** (read-only):
|
||||
- `list_calendars`
|
||||
- `find_person_in_contacts`
|
||||
- `list_talk_conversations`
|
||||
- `get_file_content`
|
||||
- `get_folder_tree`
|
||||
|
||||
**Dangerous Tools** (write operations):
|
||||
- `schedule_event`
|
||||
- `send_message_to_conversation`
|
||||
- `create_public_sharing_link`
|
||||
- `send_email`
|
||||
|
||||
### Nextcloud MCP Server Safety
|
||||
|
||||
**No built-in safety classification**:
|
||||
- All tools treated equally
|
||||
- Relies on MCP client for validation
|
||||
- OAuth scopes could control permissions
|
||||
- User must review all actions
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Nextcloud MCP Server
|
||||
|
||||
```python
|
||||
try:
|
||||
note_data = await client.notes.create_note(...)
|
||||
return CreateNoteResponse(...)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions"
|
||||
))
|
||||
elif e.response.status_code == 413:
|
||||
raise McpError(ErrorData(
|
||||
code=-1,
|
||||
message="Note content too large"
|
||||
))
|
||||
elif e.response.status_code == 409:
|
||||
raise McpError(ErrorData(
|
||||
code=-1,
|
||||
message="Note with this title already exists"
|
||||
))
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Comprehensive HTTP status code handling
|
||||
- User-friendly error messages
|
||||
- Specific error codes
|
||||
- Guidance on resolution
|
||||
|
||||
### Context Agent
|
||||
|
||||
```python
|
||||
def schedule_event(...):
|
||||
"""Create event"""
|
||||
# ... implementation
|
||||
calendar.add_event(str(c))
|
||||
return True # Simple boolean return
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Minimal error handling
|
||||
- Exceptions propagate to agent
|
||||
- LangChain handles retries
|
||||
- Agent interprets failures
|
||||
|
||||
## Use Cases
|
||||
|
||||
### When to Use Nextcloud MCP Server
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Root[Nextcloud MCP Server]
|
||||
|
||||
Root --> ExtAccess[External Access]
|
||||
Root --> OAuth[OAuth Security]
|
||||
Root --> DeepAPI[Deep API Access]
|
||||
Root --> Deploy[Standalone Deployment]
|
||||
|
||||
ExtAccess --> EA1[Claude Code integration]
|
||||
ExtAccess --> EA2[IDE plugins with MCP]
|
||||
ExtAccess --> EA3[Custom MCP clients]
|
||||
ExtAccess --> EA4[Cross-platform tools]
|
||||
|
||||
OAuth --> O1[Token-based auth]
|
||||
OAuth --> O2[OIDC compliance]
|
||||
OAuth --> O3[Per-user permissions]
|
||||
OAuth --> O4[Secure external access]
|
||||
|
||||
DeepAPI --> DA1[Full CRUD operations]
|
||||
DeepAPI --> DA2[Notes management]
|
||||
DeepAPI --> DA3[Calendar CalDAV]
|
||||
DeepAPI --> DA4[Contacts CardDAV]
|
||||
DeepAPI --> DA5[File operations]
|
||||
DeepAPI --> DA6[Table data]
|
||||
|
||||
Deploy --> D1[Docker containers]
|
||||
Deploy --> D2[Cloud VMs]
|
||||
Deploy --> D3[Kubernetes]
|
||||
Deploy --> D4[On-premise servers]
|
||||
|
||||
classDef rootStyle fill:#4a90e2,stroke:#2e5c8a,color:#fff
|
||||
classDef categoryStyle fill:#f39c12,stroke:#d68910,color:#fff
|
||||
classDef itemStyle fill:#e8f5e9,stroke:#81c784
|
||||
|
||||
class Root rootStyle
|
||||
class ExtAccess,OAuth,DeepAPI,Deploy categoryStyle
|
||||
class EA1,EA2,EA3,EA4,O1,O2,O3,O4,DA1,DA2,DA3,DA4,DA5,DA6,D1,D2,D3,D4 itemStyle
|
||||
```
|
||||
|
||||
**Best for**:
|
||||
1. External clients accessing Nextcloud (Claude Code, IDEs)
|
||||
2. OAuth/OIDC authentication requirements
|
||||
3. Full CRUD on Notes, Calendar, Contacts, Tables
|
||||
4. WebDAV file system access
|
||||
5. MCP Resources for structured data
|
||||
6. Flexible deployment scenarios
|
||||
7. Building external integrations
|
||||
|
||||
### When to Use Context Agent MCP Server
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Root[Context Agent MCP]
|
||||
|
||||
Root --> Assistant[AI Assistant]
|
||||
Root --> ActionOriented[Action-Oriented]
|
||||
Root --> MCPAgg[MCP Aggregation]
|
||||
Root --> Safety[Safety Features]
|
||||
|
||||
Assistant --> A1[Nextcloud UI integration]
|
||||
Assistant --> A2[Task Processing API]
|
||||
Assistant --> A3[User requests in Assistant]
|
||||
Assistant --> A4[Human-in-the-loop]
|
||||
|
||||
ActionOriented --> AO1[Send emails]
|
||||
ActionOriented --> AO2[Create calendar events]
|
||||
ActionOriented --> AO3[Post Talk messages]
|
||||
ActionOriented --> AO4[Generate images]
|
||||
ActionOriented --> AO5[Search web]
|
||||
|
||||
MCPAgg --> M1[Consume external MCP servers]
|
||||
MCPAgg --> M2[Weather services]
|
||||
MCPAgg --> M3[Maps and transit]
|
||||
MCPAgg --> M4[Custom integrations]
|
||||
MCPAgg --> M5[Unified tool interface]
|
||||
|
||||
Safety --> S1[Read operations auto-execute]
|
||||
Safety --> S2[Write operations require approval]
|
||||
Safety --> S3[User confirmation flow]
|
||||
Safety --> S4[Agent safety]
|
||||
|
||||
classDef rootStyle fill:#9b59b6,stroke:#6c3483,color:#fff
|
||||
classDef categoryStyle fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||
classDef itemStyle fill:#fff4e1,stroke:#f39c12
|
||||
|
||||
class Root rootStyle
|
||||
class Assistant,ActionOriented,MCPAgg,Safety categoryStyle
|
||||
class A1,A2,A3,A4,AO1,AO2,AO3,AO4,AO5,M1,M2,M3,M4,M5,S1,S2,S3,S4 itemStyle
|
||||
```
|
||||
|
||||
**Best for**:
|
||||
1. AI-driven actions inside Nextcloud UI
|
||||
2. Assistant app integration
|
||||
3. Safe/dangerous tool distinction
|
||||
4. Talk, Mail, Deck operations
|
||||
5. AI features (image gen, audio2text)
|
||||
6. Web search and maps
|
||||
7. Aggregating external MCP servers
|
||||
8. Agent acting on behalf of users
|
||||
|
||||
## Complementary Architecture
|
||||
|
||||
The two MCP servers can work together in complementary ways:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
User[User] -->|Requests AI assistance| Assistant[Nextcloud Assistant App]
|
||||
|
||||
Assistant --> ContextAgent[Context Agent]
|
||||
|
||||
subgraph ContextAgent["Context Agent (Inside Nextcloud)"]
|
||||
direction TB
|
||||
Agent[LangGraph Agent]
|
||||
MCPServer[MCP Server /mcp]
|
||||
ToolLoader[Tool Loader]
|
||||
|
||||
Agent --> ToolLoader
|
||||
ToolLoader --> InternalTools[Internal Tools<br/>Talk, Mail, Calendar]
|
||||
end
|
||||
|
||||
subgraph ExternalMCP["External MCP Ecosystem"]
|
||||
NextcloudMCP[Nextcloud MCP Server<br/>This Project]
|
||||
WeatherMCP[Weather MCP]
|
||||
CustomMCP[Custom MCP Services]
|
||||
end
|
||||
|
||||
ToolLoader -->|Consumes| NextcloudMCP
|
||||
ToolLoader -->|Consumes| WeatherMCP
|
||||
ToolLoader -->|Consumes| CustomMCP
|
||||
|
||||
subgraph ExternalClients["External Clients"]
|
||||
Claude[Claude Code]
|
||||
IDE[IDEs with MCP]
|
||||
end
|
||||
|
||||
Claude -->|Direct access| NextcloudMCP
|
||||
IDE -->|Direct access| NextcloudMCP
|
||||
|
||||
NextcloudMCP -->|OAuth/HTTP| NextcloudApps[Nextcloud Apps<br/>Notes, Calendar, Files]
|
||||
InternalTools -->|nc-py-api| NextcloudApps
|
||||
|
||||
classDef internal fill:#e8f5e9
|
||||
classDef external fill:#e1f5ff
|
||||
classDef mcp fill:#fff4e1
|
||||
|
||||
class Assistant,Agent,MCPServer,ToolLoader,InternalTools,NextcloudApps internal
|
||||
class Claude,IDE external
|
||||
class NextcloudMCP,WeatherMCP,CustomMCP mcp
|
||||
```
|
||||
|
||||
### Example Workflows
|
||||
|
||||
**Workflow 1: External Client → Nextcloud MCP Server**
|
||||
```
|
||||
Claude Code → Nextcloud MCP Server → Nextcloud Notes API
|
||||
```
|
||||
- User asks Claude Code to search notes
|
||||
- Claude Code calls `nc_notes_search_notes` tool
|
||||
- Returns results directly to user
|
||||
|
||||
**Workflow 2: Assistant → Context Agent → Internal Tools**
|
||||
```
|
||||
User → Assistant → Context Agent → Send Email Tool
|
||||
```
|
||||
- User asks Assistant to send an email
|
||||
- Context Agent identifies "send_email" as dangerous
|
||||
- Requests user confirmation
|
||||
- Sends email via nc-py-api
|
||||
|
||||
**Workflow 3: Assistant → Context Agent → External MCP**
|
||||
```
|
||||
User → Assistant → Context Agent → Nextcloud MCP Server → Notes
|
||||
```
|
||||
- User asks Assistant about notes
|
||||
- Context Agent consumes Nextcloud MCP Server as external MCP
|
||||
- Gets notes data via MCP protocol
|
||||
- Returns to user via Assistant
|
||||
|
||||
## Technical Comparison Matrix
|
||||
|
||||
| Aspect | Nextcloud MCP Server | Context Agent MCP |
|
||||
|--------|---------------------|-------------------|
|
||||
| **Framework** | FastMCP (native) | FastMCP + LangChain |
|
||||
| **Tool Decorator** | `@mcp.tool()` | `@tool` from LangChain |
|
||||
| **Tool Loading** | Static (startup) | Dynamic (runtime) |
|
||||
| **Tool Refresh** | No (restart required) | Every 60 seconds |
|
||||
| **Resources** | Yes (`@mcp.resource()`) | No |
|
||||
| **Transports** | SSE, HTTP, Streamable-HTTP | Stateless HTTP only |
|
||||
| **MCP Mode** | Server only | Server + Client (hybrid) |
|
||||
| **Client Type** | httpx (custom HTTP) | nc-py-api (native) |
|
||||
| **Deployment** | Standalone external | Inside Nextcloud (ExApp) |
|
||||
| **Auth** | BasicAuth or OAuth/OIDC | Session-based (ExApp) |
|
||||
| **User Context** | Shared or per-token | Per-request `nc.set_user()` |
|
||||
| **Error Handling** | McpError with codes | Basic exceptions |
|
||||
| **Type Safety** | Pydantic models | Python types |
|
||||
| **Safety Model** | No built-in | Safe/Dangerous classification |
|
||||
| **Dependencies** | FastMCP, httpx, Pydantic | nc-py-api, LangChain, LangGraph |
|
||||
| **Integration** | HTTP APIs | AppAPI + Task Processing |
|
||||
| **External MCP** | No | Yes (consumes) |
|
||||
|
||||
## Summary
|
||||
|
||||
Both MCP servers serve important but different roles in the Nextcloud ecosystem:
|
||||
|
||||
### Nextcloud MCP Server (This Project)
|
||||
- **Purpose**: Expose Nextcloud to external MCP clients
|
||||
- **Strength**: Deep CRUD operations, OAuth security, standalone deployment
|
||||
- **Audience**: External developers, Claude Code users, integration builders
|
||||
|
||||
### Context Agent MCP Server
|
||||
- **Purpose**: Bring AI agent capabilities to Nextcloud users
|
||||
- **Strength**: Action-oriented, safe/dangerous tools, MCP aggregation
|
||||
- **Audience**: Nextcloud users via Assistant app, AI-driven workflows
|
||||
|
||||
**Key Insight**: These are complementary, not competing. Context Agent could even consume Nextcloud MCP Server as one of its external MCP sources, creating a unified ecosystem where:
|
||||
- External clients access Nextcloud via Nextcloud MCP Server
|
||||
- Internal users leverage Context Agent for AI assistance
|
||||
- Context Agent aggregates both internal tools and external MCP servers (including Nextcloud MCP Server)
|
||||
@@ -0,0 +1,189 @@
|
||||
# Cookbook App
|
||||
|
||||
### Cookbook Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_cookbook_import_recipe` | Import a recipe from a URL using schema.org metadata |
|
||||
| `nc_cookbook_create_recipe` | Create a new recipe with all schema.org fields |
|
||||
| `nc_cookbook_get_recipe` | Get a specific recipe by ID |
|
||||
| `nc_cookbook_update_recipe` | Update an existing recipe |
|
||||
| `nc_cookbook_delete_recipe` | Delete a recipe permanently |
|
||||
| `nc_cookbook_list_recipes` | Get all recipes in the database |
|
||||
| `nc_cookbook_search_recipes` | Search for recipes by keywords, tags, and categories |
|
||||
| `nc_cookbook_list_categories` | Get all known recipe categories |
|
||||
| `nc_cookbook_get_recipes_in_category` | Get all recipes in a specific category |
|
||||
| `nc_cookbook_list_keywords` | Get all known recipe keywords/tags |
|
||||
| `nc_cookbook_get_recipes_with_keywords` | Get all recipes that have specific keywords |
|
||||
| `nc_cookbook_set_config` | Set Cookbook app configuration |
|
||||
| `nc_cookbook_reindex` | Trigger a rescan of all recipes into the search database |
|
||||
|
||||
### Cookbook Resources
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `cookbook://version` | Get Cookbook app and API version information |
|
||||
| `cookbook://config` | Get Cookbook app configuration |
|
||||
| `nc://Cookbook/{recipe_id}` | Get a specific recipe by ID |
|
||||
|
||||
## Recipe Management
|
||||
|
||||
The server provides complete Nextcloud Cookbook integration, enabling you to manage your recipe collection:
|
||||
|
||||
- **Import recipes from websites** using schema.org metadata
|
||||
- Full CRUD operations for recipes
|
||||
- Search and organize with categories and keywords
|
||||
- Support for structured recipe data (ingredients, instructions, nutrition, etc.)
|
||||
- Configure app settings and trigger reindexing
|
||||
|
||||
### Schema.org Recipe Format
|
||||
|
||||
The Cookbook app uses the [schema.org/Recipe](https://schema.org/Recipe) specification for structured recipe data. This standard format includes:
|
||||
|
||||
- **Basic info**: Name, description, image, URL
|
||||
- **Timing**: Preparation time, cooking time, total time (ISO8601 format like `PT30M`)
|
||||
- **Ingredients**: List of ingredients with quantities
|
||||
- **Instructions**: Step-by-step cooking instructions
|
||||
- **Metadata**: Category, keywords/tags, yield (servings)
|
||||
- **Nutrition**: Optional nutrition information
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Import Recipe from URL
|
||||
|
||||
Many recipe websites include schema.org metadata. The import tool automatically extracts this data:
|
||||
|
||||
```python
|
||||
# Import from a recipe website
|
||||
await nc_cookbook_import_recipe(
|
||||
url="https://www.example.com/recipes/chocolate-cake"
|
||||
)
|
||||
# Returns: Recipe object with all extracted data
|
||||
```
|
||||
|
||||
#### Create Recipe Manually
|
||||
|
||||
```python
|
||||
# Create a new recipe from scratch
|
||||
await nc_cookbook_create_recipe(
|
||||
name="Homemade Pizza",
|
||||
description="Classic homemade pizza with fresh ingredients",
|
||||
ingredients=[
|
||||
"500g pizza dough",
|
||||
"200g tomato sauce",
|
||||
"300g mozzarella cheese",
|
||||
"Fresh basil leaves",
|
||||
"Olive oil"
|
||||
],
|
||||
instructions=[
|
||||
"Preheat oven to 250°C (480°F)",
|
||||
"Roll out the pizza dough",
|
||||
"Spread tomato sauce evenly",
|
||||
"Add mozzarella cheese",
|
||||
"Bake for 10-12 minutes",
|
||||
"Top with fresh basil and olive oil"
|
||||
],
|
||||
category="Main Course",
|
||||
keywords="italian,vegetarian,quick",
|
||||
prep_time="PT20M", # 20 minutes
|
||||
cook_time="PT12M", # 12 minutes
|
||||
total_time="PT32M", # 32 minutes
|
||||
recipe_yield=4 # 4 servings
|
||||
)
|
||||
```
|
||||
|
||||
#### Update Recipe
|
||||
|
||||
```python
|
||||
# Update recipe details (only specified fields are changed)
|
||||
await nc_cookbook_update_recipe(
|
||||
recipe_id=123,
|
||||
description="Updated: Classic homemade pizza - now with video tutorial!",
|
||||
url="https://example.com/videos/pizza-tutorial",
|
||||
keywords="italian,vegetarian,quick,video"
|
||||
)
|
||||
```
|
||||
|
||||
#### Search and Filter
|
||||
|
||||
```python
|
||||
# Search recipes by keyword
|
||||
results = await nc_cookbook_search_recipes(query="chocolate")
|
||||
|
||||
# List all categories
|
||||
categories = await nc_cookbook_list_categories()
|
||||
# Returns: [{"name": "Desserts", "recipe_count": 15}, ...]
|
||||
|
||||
# Get recipes in a category
|
||||
desserts = await nc_cookbook_get_recipes_in_category(category="Desserts")
|
||||
|
||||
# List all keywords/tags
|
||||
keywords = await nc_cookbook_list_keywords()
|
||||
# Returns: [{"name": "chocolate", "recipe_count": 8}, ...]
|
||||
|
||||
# Get recipes with specific tags
|
||||
quick_meals = await nc_cookbook_get_recipes_with_keywords(keywords=["quick", "30min"])
|
||||
```
|
||||
|
||||
#### Manage Configuration
|
||||
|
||||
```python
|
||||
# Configure the Cookbook app
|
||||
await nc_cookbook_set_config(
|
||||
folder="Recipes", # Folder path in user's files
|
||||
update_interval=15, # Auto-rescan every 15 minutes
|
||||
print_image=True # Print images with recipes
|
||||
)
|
||||
|
||||
# Trigger manual reindex after file changes
|
||||
await nc_cookbook_reindex()
|
||||
```
|
||||
|
||||
### Time Format (ISO8601 Duration)
|
||||
|
||||
Recipe times use ISO8601 duration format:
|
||||
|
||||
| Duration | Format | Example |
|
||||
|----------|--------|---------|
|
||||
| 15 minutes | `PT15M` | Prep time |
|
||||
| 1 hour | `PT1H` | Baking time |
|
||||
| 1 hour 30 minutes | `PT1H30M` | Total time |
|
||||
| 45 seconds | `PT45S` | Mixing time |
|
||||
| 2 hours 15 minutes | `PT2H15M` | Slow cooking |
|
||||
|
||||
### Tips for Recipe Import
|
||||
|
||||
**Best practices for importing recipes from URLs:**
|
||||
|
||||
1. **Look for schema.org support**: Most modern recipe sites include schema.org metadata
|
||||
2. **Check import quality**: Review imported recipes for completeness
|
||||
3. **Handle duplicates**: The API prevents duplicate imports by recipe name
|
||||
4. **Edit after import**: Update imported recipes with personal notes or adjustments
|
||||
|
||||
**Common recipe websites with good schema.org support:**
|
||||
- AllRecipes
|
||||
- Food Network
|
||||
- BBC Good Food
|
||||
- Serious Eats
|
||||
- Bon Appétit
|
||||
- Many food blogs using recipe plugins
|
||||
|
||||
### Organizing Your Recipes
|
||||
|
||||
**Categories**: Organize recipes by type (Appetizers, Main Course, Desserts, etc.)
|
||||
- Use `nc_cookbook_list_categories` to see all categories
|
||||
- Filter by category with `nc_cookbook_get_recipes_in_category`
|
||||
|
||||
**Keywords/Tags**: Tag recipes with searchable terms (vegetarian, quick, spicy, etc.)
|
||||
- Use `nc_cookbook_list_keywords` to see all tags
|
||||
- Filter by tags with `nc_cookbook_get_recipes_with_keywords`
|
||||
- Search across all fields with `nc_cookbook_search_recipes`
|
||||
|
||||
**Reindexing**: The Cookbook app maintains a search index
|
||||
- Automatically scans at configured intervals
|
||||
- Manually trigger with `nc_cookbook_reindex` after bulk changes
|
||||
- Required after modifying recipe files directly in WebDAV
|
||||
|
||||
## API Reference
|
||||
|
||||
For detailed API documentation, see the [Nextcloud Cookbook OpenAPI specification](https://github.com/nextcloud/cookbook/tree/master/docs/dev/api/0.1.2).
|
||||
@@ -217,11 +217,12 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
|
||||
**How it works**:
|
||||
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
||||
2. Calls `/apps/oidc/register` to register new client
|
||||
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||
3. Saves credentials to `.nextcloud_oauth_client.json`
|
||||
4. Re-registers if credentials expire
|
||||
4. Reuses these credentials on subsequent startups
|
||||
5. Re-registers only if credentials are missing or expired
|
||||
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
### Pre-configured Client
|
||||
|
||||
|
||||
+7
-7
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
|
||||
|
||||
### Mode A: Automatic Registration (Dynamic Client Registration)
|
||||
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
**How it works**:
|
||||
- MCP server automatically registers OAuth client at startup
|
||||
- MCP server automatically registers an OAuth client on first startup
|
||||
- Uses Nextcloud's dynamic client registration endpoint
|
||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
||||
- Reuses stored credentials on subsequent restarts
|
||||
- Re-registers automatically if credentials expire
|
||||
|
||||
**Pros**:
|
||||
- Zero configuration required
|
||||
- Quick setup
|
||||
- No manual client management
|
||||
- Automatic credential management
|
||||
|
||||
**Cons**:
|
||||
- Clients expire (default: 1 hour, configurable)
|
||||
- Must re-register on restart if expired
|
||||
- Not ideal for long-running production
|
||||
- Must have dynamic client registration enabled on Nextcloud
|
||||
|
||||
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
|
||||
|
||||
@@ -192,8 +192,8 @@ You have two options for managing OAuth clients:
|
||||
**Best for**: Production, long-running deployments, stable environments
|
||||
|
||||
**How it works**:
|
||||
- You manually register OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server
|
||||
- You manually register an OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server via environment variables
|
||||
- Credentials don't expire
|
||||
|
||||
**Pros**:
|
||||
|
||||
@@ -151,11 +151,11 @@ curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
This quick start uses **automatic client registration** which is perfect for:
|
||||
- Development
|
||||
- Testing
|
||||
- Short-lived deployments
|
||||
- Quick deployments
|
||||
|
||||
For **production deployments**, you should:
|
||||
1. Pre-register OAuth clients manually
|
||||
2. Use dedicated client credentials
|
||||
For **production deployments**, consider:
|
||||
1. Pre-registering OAuth client manually
|
||||
2. Using dedicated client credentials that don't expire
|
||||
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
|
||||
|
||||
---
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,
|
||||
@@ -379,6 +380,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"sharing": configure_sharing_tools,
|
||||
"calendar": configure_calendar_tools,
|
||||
"contacts": configure_contacts_tools,
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
}
|
||||
|
||||
@@ -444,7 +446,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"--enable-app",
|
||||
"-e",
|
||||
multiple=True,
|
||||
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
||||
type=click.Choice(
|
||||
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
|
||||
),
|
||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||
)
|
||||
@click.option(
|
||||
|
||||
@@ -211,7 +211,6 @@ async def load_or_register_client(
|
||||
storage_path: str | Path,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
force_register: bool = True,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
@@ -219,7 +218,7 @@ async def load_or_register_client(
|
||||
This function:
|
||||
1. Checks for existing client credentials in storage
|
||||
2. Validates the credentials are not expired
|
||||
3. Registers a new client if needed
|
||||
3. Registers a new client if needed (no stored credentials or expired)
|
||||
4. Saves the new client credentials
|
||||
|
||||
Args:
|
||||
@@ -228,7 +227,6 @@ async def load_or_register_client(
|
||||
storage_path: Path to store client credentials
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
force_register: Force registration even if valid credentials exist
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -239,11 +237,10 @@ async def load_or_register_client(
|
||||
"""
|
||||
storage_path = Path(storage_path)
|
||||
|
||||
# Try to load existing client unless forced to register
|
||||
if not force_register:
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
# Try to load existing client
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
|
||||
@@ -9,11 +9,13 @@ from httpx import (
|
||||
BasicAuth,
|
||||
Request,
|
||||
Response,
|
||||
Timeout,
|
||||
)
|
||||
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .notes import NotesClient
|
||||
@@ -65,6 +67,9 @@ class NextcloudClient:
|
||||
auth=auth,
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
timeout=Timeout(
|
||||
30.0
|
||||
), # 30 second timeout for all operations including recipe imports
|
||||
)
|
||||
|
||||
# Initialize app clients
|
||||
@@ -73,6 +78,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)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Client for Nextcloud Cookbook app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CookbookClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Cookbook app operations."""
|
||||
|
||||
async def get_version(self) -> Dict[str, Any]:
|
||||
"""Get Cookbook app and API version."""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/version")
|
||||
return response.json()
|
||||
|
||||
async def get_config(self) -> Dict[str, Any]:
|
||||
"""Get current Cookbook app configuration."""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/config")
|
||||
return response.json()
|
||||
|
||||
async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set Cookbook app configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with fields like:
|
||||
- folder: Recipe folder path
|
||||
- update_interval: Auto-rescan interval in minutes
|
||||
- print_image: Whether to print images with recipes
|
||||
- visibleInfoBlocks: Visible info blocks configuration
|
||||
|
||||
Returns:
|
||||
Response with status message
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/config", json=config
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def reindex(self) -> str:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex")
|
||||
return response.json()
|
||||
|
||||
async def list_recipes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes in the database.
|
||||
|
||||
Returns:
|
||||
List of recipe stubs with basic information
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes")
|
||||
return response.json()
|
||||
|
||||
async def get_recipe(self, recipe_id: int) -> Dict[str, Any]:
|
||||
"""Get a single recipe by ID.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID
|
||||
|
||||
Returns:
|
||||
Full recipe data
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_recipe(self, recipe_data: Dict[str, Any]) -> int:
|
||||
"""Create a new recipe.
|
||||
|
||||
Args:
|
||||
recipe_data: Recipe data following schema.org/Recipe format.
|
||||
Required: name
|
||||
Optional: description, ingredients, instructions, etc.
|
||||
|
||||
Returns:
|
||||
ID of the newly created recipe
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int:
|
||||
"""Update an existing recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID to update
|
||||
recipe_data: Updated recipe data
|
||||
|
||||
Returns:
|
||||
ID of the updated recipe
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_recipe(self, recipe_id: int) -> str:
|
||||
"""Delete a recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID to delete
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def import_recipe(self, url: str) -> Dict[str, Any]:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
Args:
|
||||
url: URL of the recipe to import
|
||||
|
||||
Returns:
|
||||
Full imported recipe data
|
||||
"""
|
||||
logger.info(f"Importing recipe from URL: {url}")
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/import", json={"url": url}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes:
|
||||
"""Get the main image of a recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID
|
||||
size: Image size - "full", "thumb" (250px), or "thumb16" (16px)
|
||||
|
||||
Returns:
|
||||
Image bytes
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/cookbook/api/v1/recipes/{recipe_id}/image",
|
||||
params={"size": size},
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def search_recipes(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Search for recipes by keywords, tags, and categories.
|
||||
|
||||
Args:
|
||||
query: Search string (URL-encoded, space/comma separated)
|
||||
|
||||
Returns:
|
||||
List of matching recipe stubs
|
||||
"""
|
||||
# URL encode the query
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_query = quote(query)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_categories(self) -> List[Dict[str, Any]]:
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category.
|
||||
|
||||
Returns:
|
||||
List of categories with recipe counts
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/categories")
|
||||
return response.json()
|
||||
|
||||
async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Args:
|
||||
category: Category name (use "_" for recipes with no category)
|
||||
|
||||
Returns:
|
||||
List of recipe stubs in the category
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_category = quote(category)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def rename_category(self, old_name: str, new_name: str) -> str:
|
||||
"""Rename a category.
|
||||
|
||||
Args:
|
||||
old_name: Current category name
|
||||
new_name: New category name
|
||||
|
||||
Returns:
|
||||
New category name
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_old_name = quote(old_name)
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/cookbook/api/v1/category/{encoded_old_name}",
|
||||
json={"name": new_name},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_keywords(self) -> List[Dict[str, Any]]:
|
||||
"""Get all known keywords/tags.
|
||||
|
||||
Returns:
|
||||
List of keywords with recipe counts
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords")
|
||||
return response.json()
|
||||
|
||||
async def get_recipes_with_keywords(
|
||||
self, keywords: List[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes associated with certain keywords.
|
||||
|
||||
Args:
|
||||
keywords: List of keywords to filter by
|
||||
|
||||
Returns:
|
||||
List of recipe stubs matching the keywords
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# Join keywords with commas
|
||||
keywords_str = ",".join(keywords)
|
||||
encoded_keywords = quote(keywords_str)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}"
|
||||
)
|
||||
return response.json()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Pydantic models for Cookbook app responses."""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class Nutrition(BaseModel):
|
||||
"""Nutrition information following schema.org/NutritionInformation."""
|
||||
|
||||
type: str = Field(
|
||||
default="NutritionInformation",
|
||||
alias="@type",
|
||||
description="Schema.org object type",
|
||||
)
|
||||
calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')")
|
||||
carbohydrateContent: Optional[str] = Field(
|
||||
None, description="Carbohydrates (e.g., '300 g')"
|
||||
)
|
||||
cholesterolContent: Optional[str] = Field(
|
||||
None, description="Cholesterol (e.g., '10 g')"
|
||||
)
|
||||
fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')")
|
||||
fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')")
|
||||
proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')")
|
||||
saturatedFatContent: Optional[str] = Field(
|
||||
None, description="Saturated fat (e.g., '5 g')"
|
||||
)
|
||||
servingSize: Optional[str] = Field(
|
||||
None, description="Serving size description (e.g., 'One plate')"
|
||||
)
|
||||
sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')")
|
||||
sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')")
|
||||
transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')")
|
||||
unsaturatedFatContent: Optional[str] = Field(
|
||||
None, description="Unsaturated fat (e.g., '40 g')"
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class RecipeStub(BaseModel):
|
||||
"""Stub of a recipe with basic information."""
|
||||
|
||||
id: str = Field(description="Recipe ID as string")
|
||||
recipe_id: int = Field(description="Recipe ID as integer (deprecated)")
|
||||
name: str = Field(description="Recipe name")
|
||||
keywords: Optional[str] = Field(default="", description="Comma-separated keywords")
|
||||
dateCreated: str = Field(description="Creation date (ISO8601)")
|
||||
dateModified: Optional[str] = Field(
|
||||
None, description="Last modified date (ISO8601)"
|
||||
)
|
||||
imageUrl: str = Field(default="", description="URL of the recipe image")
|
||||
imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image")
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
"""Full recipe following schema.org/Recipe specification."""
|
||||
|
||||
type: str = Field(default="Recipe", alias="@type", description="Schema.org type")
|
||||
id: Optional[str] = Field(None, description="Recipe ID")
|
||||
name: str = Field(description="Recipe name")
|
||||
description: str = Field(default="", description="Recipe description")
|
||||
url: str = Field(default="", description="Original recipe URL")
|
||||
image: str = Field(default="", description="URL of original recipe image")
|
||||
imageUrl: Optional[str] = Field(
|
||||
None, description="URL of the recipe image in Nextcloud"
|
||||
)
|
||||
imagePlaceholderUrl: Optional[str] = Field(
|
||||
None, description="URL of placeholder image"
|
||||
)
|
||||
keywords: str = Field(default="", description="Comma-separated keywords")
|
||||
dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)")
|
||||
dateModified: Optional[str] = Field(
|
||||
None, description="Last modified date (ISO8601)"
|
||||
)
|
||||
prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)")
|
||||
cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)")
|
||||
totalTime: Optional[str] = Field(None, description="Total time (ISO8601)")
|
||||
recipeYield: Union[int, str] = Field(default=1, description="Number of servings")
|
||||
recipeCategory: str = Field(default="", description="Recipe category")
|
||||
tool: List[str] = Field(default_factory=list, description="Required tools")
|
||||
recipeIngredient: List[str] = Field(
|
||||
default_factory=list, description="List of ingredients"
|
||||
)
|
||||
recipeInstructions: List[str] = Field(
|
||||
default_factory=list, description="Cooking instructions"
|
||||
)
|
||||
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "allow" # Allow additional schema.org fields
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
"""A recipe category."""
|
||||
|
||||
name: str = Field(description="Category name")
|
||||
recipe_count: int = Field(description="Number of recipes in category")
|
||||
|
||||
|
||||
class Keyword(BaseModel):
|
||||
"""A recipe keyword/tag."""
|
||||
|
||||
name: str = Field(description="Keyword name")
|
||||
recipe_count: int = Field(description="Number of recipes with this keyword")
|
||||
|
||||
|
||||
class VisibleInfoBlocks(BaseModel):
|
||||
"""Configuration for visible information blocks in the UI."""
|
||||
|
||||
preparation_time: Optional[bool] = Field(
|
||||
None, alias="preparation-time", description="Show preparation time"
|
||||
)
|
||||
cooking_time: Optional[bool] = Field(
|
||||
None, alias="cooking-time", description="Show cooking time"
|
||||
)
|
||||
total_time: Optional[bool] = Field(
|
||||
None, alias="total-time", description="Show total time"
|
||||
)
|
||||
nutrition_information: Optional[bool] = Field(
|
||||
None, alias="nutrition-information", description="Show nutrition info"
|
||||
)
|
||||
tools: Optional[bool] = Field(None, description="Show tools list")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class CookbookConfig(BaseModel):
|
||||
"""Cookbook app configuration."""
|
||||
|
||||
folder: Optional[str] = Field(None, description="Recipe folder path")
|
||||
update_interval: Optional[int] = Field(
|
||||
None, description="Auto-rescan interval in minutes"
|
||||
)
|
||||
print_image: Optional[bool] = Field(None, description="Print images with recipes")
|
||||
visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field(
|
||||
None, description="Visible info blocks configuration"
|
||||
)
|
||||
|
||||
|
||||
class APIVersion(BaseModel):
|
||||
"""API version information."""
|
||||
|
||||
epoch: int = Field(description="API epoch")
|
||||
major: int = Field(description="Major version")
|
||||
minor: int = Field(description="Minor version")
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
"""Version information for Cookbook app and API."""
|
||||
|
||||
cookbook_version: List[int] = Field(description="Cookbook app version")
|
||||
api_version: APIVersion = Field(description="API version")
|
||||
|
||||
|
||||
# Response models for MCP tools
|
||||
|
||||
|
||||
class ImportRecipeResponse(BaseResponse):
|
||||
"""Response model for recipe import."""
|
||||
|
||||
recipe: Recipe = Field(description="The imported recipe")
|
||||
recipe_id: str = Field(description="ID of the imported recipe")
|
||||
|
||||
|
||||
class CreateRecipeResponse(IdResponse):
|
||||
"""Response model for recipe creation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UpdateRecipeResponse(IdResponse):
|
||||
"""Response model for recipe update."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeleteRecipeResponse(StatusResponse):
|
||||
"""Response model for recipe deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of deleted recipe")
|
||||
|
||||
|
||||
class ListRecipesResponse(BaseResponse):
|
||||
"""Response model for listing recipes."""
|
||||
|
||||
recipes: List[RecipeStub] = Field(description="List of recipe stubs")
|
||||
total_count: int = Field(description="Total number of recipes")
|
||||
|
||||
|
||||
class SearchRecipesResponse(BaseResponse):
|
||||
"""Response model for recipe search."""
|
||||
|
||||
recipes: List[RecipeStub] = Field(description="Matching recipes")
|
||||
query: str = Field(description="Search query used")
|
||||
total_found: int = Field(description="Number of recipes found")
|
||||
|
||||
|
||||
class ListCategoriesResponse(BaseResponse):
|
||||
"""Response model for listing categories."""
|
||||
|
||||
categories: List[Category] = Field(description="List of categories")
|
||||
|
||||
|
||||
class ListKeywordsResponse(BaseResponse):
|
||||
"""Response model for listing keywords."""
|
||||
|
||||
keywords: List[Keyword] = Field(description="List of keywords")
|
||||
|
||||
|
||||
class ReindexResponse(StatusResponse):
|
||||
"""Response model for reindex operation."""
|
||||
|
||||
pass
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -9,6 +10,7 @@ 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",
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.cookbook import (
|
||||
Category,
|
||||
CookbookConfig,
|
||||
CreateRecipeResponse,
|
||||
DeleteRecipeResponse,
|
||||
ImportRecipeResponse,
|
||||
Keyword,
|
||||
ListCategoriesResponse,
|
||||
ListKeywordsResponse,
|
||||
ListRecipesResponse,
|
||||
Recipe,
|
||||
RecipeStub,
|
||||
ReindexResponse,
|
||||
SearchRecipesResponse,
|
||||
UpdateRecipeResponse,
|
||||
Version,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_cookbook_tools(mcp: FastMCP):
|
||||
@mcp.resource("cookbook://version")
|
||||
async def cookbook_get_version():
|
||||
"""Get the Cookbook app and API version"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
version_data = await client.cookbook.get_version()
|
||||
return Version(**version_data)
|
||||
|
||||
@mcp.resource("cookbook://config")
|
||||
async def cookbook_get_config():
|
||||
"""Get the Cookbook app configuration"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
config_data = await client.cookbook.get_config()
|
||||
return CookbookConfig(**config_data)
|
||||
|
||||
@mcp.resource("nc://Cookbook/{recipe_id}")
|
||||
async def nc_cookbook_get_recipe_resource(recipe_id: int):
|
||||
"""Get a recipe by ID using resource URI"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
This extracts recipe data from websites that use schema.org Recipe markup.
|
||||
Many popular recipe sites support this standard."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.import_recipe(url)
|
||||
recipe = Recipe(**recipe_data)
|
||||
return ImportRecipeResponse(
|
||||
recipe=recipe,
|
||||
recipe_id=recipe.id or "unknown",
|
||||
)
|
||||
except RequestError as e:
|
||||
# RequestError can have empty str() - get details from exception attributes
|
||||
error_detail = (
|
||||
str(e)
|
||||
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
|
||||
)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error importing recipe from {url}: {error_detail}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 400:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Invalid URL or missing 'url' field: {url}",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 409:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="A recipe with this name already exists. Import aborted.",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to import recipes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to import recipe from {url}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.list_recipes()
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to list recipes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list recipes: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||
"""Get a specific recipe by its ID"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_create_recipe(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
ingredients: list[str] | None = None,
|
||||
instructions: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
prep_time: str | None = None,
|
||||
cook_time: str | None = None,
|
||||
total_time: str | None = None,
|
||||
recipe_yield: int | None = None,
|
||||
category: str | None = None,
|
||||
keywords: str | None = None,
|
||||
ctx: Context = None,
|
||||
) -> CreateRecipeResponse:
|
||||
"""Create a new recipe.
|
||||
|
||||
Required: name
|
||||
Optional: All other recipe fields following schema.org/Recipe format.
|
||||
|
||||
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
|
||||
client = get_client(ctx)
|
||||
|
||||
recipe_data = {"name": name}
|
||||
if description:
|
||||
recipe_data["description"] = description
|
||||
if ingredients:
|
||||
recipe_data["recipeIngredient"] = ingredients
|
||||
if instructions:
|
||||
recipe_data["recipeInstructions"] = instructions
|
||||
if url:
|
||||
recipe_data["url"] = url
|
||||
if prep_time:
|
||||
recipe_data["prepTime"] = prep_time
|
||||
if cook_time:
|
||||
recipe_data["cookTime"] = cook_time
|
||||
if total_time:
|
||||
recipe_data["totalTime"] = total_time
|
||||
if recipe_yield:
|
||||
recipe_data["recipeYield"] = recipe_yield
|
||||
if category:
|
||||
recipe_data["recipeCategory"] = category
|
||||
if keywords:
|
||||
recipe_data["keywords"] = keywords
|
||||
|
||||
try:
|
||||
recipe_id = await client.cookbook.create_recipe(recipe_data)
|
||||
return CreateRecipeResponse(id=recipe_id)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"A recipe with name '{name}' already exists",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 422:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Recipe name is required and cannot be empty",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to create recipes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to create recipe: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_update_recipe(
|
||||
recipe_id: int,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
ingredients: list[str] | None = None,
|
||||
instructions: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
prep_time: str | None = None,
|
||||
cook_time: str | None = None,
|
||||
total_time: str | None = None,
|
||||
recipe_yield: int | None = None,
|
||||
category: str | None = None,
|
||||
keywords: str | None = None,
|
||||
ctx: Context = None,
|
||||
) -> UpdateRecipeResponse:
|
||||
"""Update an existing recipe.
|
||||
|
||||
Provide only the fields you want to update. Unspecified fields remain unchanged."""
|
||||
client = get_client(ctx)
|
||||
|
||||
# First get the current recipe
|
||||
try:
|
||||
current_recipe = await client.cookbook.get_recipe(recipe_id)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
# Update only specified fields
|
||||
recipe_data = current_recipe.copy()
|
||||
if name is not None:
|
||||
recipe_data["name"] = name
|
||||
if description is not None:
|
||||
recipe_data["description"] = description
|
||||
if ingredients is not None:
|
||||
recipe_data["recipeIngredient"] = ingredients
|
||||
if instructions is not None:
|
||||
recipe_data["recipeInstructions"] = instructions
|
||||
if url is not None:
|
||||
recipe_data["url"] = url
|
||||
if prep_time is not None:
|
||||
recipe_data["prepTime"] = prep_time
|
||||
if cook_time is not None:
|
||||
recipe_data["cookTime"] = cook_time
|
||||
if total_time is not None:
|
||||
recipe_data["totalTime"] = total_time
|
||||
if recipe_yield is not None:
|
||||
recipe_data["recipeYield"] = recipe_yield
|
||||
if category is not None:
|
||||
recipe_data["recipeCategory"] = category
|
||||
if keywords is not None:
|
||||
recipe_data["keywords"] = keywords
|
||||
|
||||
try:
|
||||
updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data)
|
||||
return UpdateRecipeResponse(id=updated_id)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 422:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Recipe name is required and cannot be empty",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Access denied: insufficient permissions to update recipe {recipe_id}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_delete_recipe(
|
||||
recipe_id: int, ctx: Context
|
||||
) -> DeleteRecipeResponse:
|
||||
"""Delete a recipe permanently"""
|
||||
logger.info("Deleting recipe %s", recipe_id)
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.delete_recipe(recipe_id)
|
||||
return DeleteRecipeResponse(
|
||||
status_code=200,
|
||||
message=message,
|
||||
deleted_id=recipe_id,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Access denied: insufficient permissions to delete recipe {recipe_id}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_search_recipes(
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
"""Search for recipes by keywords, tags, and categories"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.search_recipes(query)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return SearchRecipesResponse(
|
||||
recipes=recipes, query=query, total_found=len(recipes)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to search recipes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 500:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Search failed: server error",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Search failed: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
categories_data = await client.cookbook.list_categories()
|
||||
categories = [Category(**c) for c in categories_data]
|
||||
return ListCategoriesResponse(categories=categories)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to list categories",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list categories: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_get_recipes_in_category(
|
||||
category: str, ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Use '_' as the category name to get recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_in_category(category)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to access recipes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 500:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Could not find category '{category}'",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get recipes in category: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
keywords_data = await client.cookbook.list_keywords()
|
||||
keywords = [Keyword(**k) for k in keywords_data]
|
||||
return ListKeywordsResponse(keywords=keywords)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to list keywords",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list keywords: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_get_recipes_with_keywords(
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
"""Get all recipes that have specific keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to access recipes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 500:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Failed to get recipes with keywords: server error",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get recipes with keywords: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_set_config(
|
||||
folder: str | None = None,
|
||||
update_interval: int | None = None,
|
||||
print_image: bool | None = None,
|
||||
ctx: Context = None,
|
||||
) -> ReindexResponse:
|
||||
"""Set Cookbook app configuration.
|
||||
|
||||
Args:
|
||||
folder: Recipe folder path in user's files
|
||||
update_interval: Automatic rescan interval in minutes
|
||||
print_image: Whether to print images with recipes"""
|
||||
client = get_client(ctx)
|
||||
|
||||
config_data = {}
|
||||
if folder is not None:
|
||||
config_data["folder"] = folder
|
||||
if update_interval is not None:
|
||||
config_data["update_interval"] = update_interval
|
||||
if print_image is not None:
|
||||
config_data["print_image"] = print_image
|
||||
|
||||
try:
|
||||
result = await client.cookbook.set_config(config_data)
|
||||
return ReindexResponse(status_code=200, message=str(result))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to set configuration",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to set configuration: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
This rebuilds the search index and should be used after manual file changes."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.reindex()
|
||||
return ReindexResponse(status_code=200, message=message)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to reindex",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to reindex: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
@@ -61,6 +61,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error retrieving note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -92,6 +99,10 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return CreateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
@@ -146,6 +157,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return UpdateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -192,6 +209,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return AppendContentResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error appending to note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -238,6 +262,10 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return SearchNotesResponse(
|
||||
results=results, query=query, total_found=len(results)
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
@@ -265,6 +293,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -295,6 +329,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
"mimeType": mime_type,
|
||||
"data": content,
|
||||
}
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
@@ -330,6 +371,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
message=f"Note {note_id} deleted successfully",
|
||||
deleted_id=note_id,
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
@@ -8,9 +8,9 @@ authors = [
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.17,<1.18)",
|
||||
"mcp[cli] (>=1.18,<1.19)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"pillow (>=12.0.0,<12.1.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
@@ -22,8 +22,8 @@ asyncio_mode = "auto"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
log_cli = 1
|
||||
log_cli_level = "INFO"
|
||||
log_level = "INFO"
|
||||
log_cli_level = "WARN"
|
||||
log_level = "WARN"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_cookbook_version(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app version."""
|
||||
logger.info("Getting Cookbook app version")
|
||||
version_data = await nc_client.cookbook.get_version()
|
||||
|
||||
assert "cookbook_version" in version_data
|
||||
assert "api_version" in version_data
|
||||
logger.info(f"Cookbook version: {version_data}")
|
||||
|
||||
|
||||
async def test_cookbook_config(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app configuration."""
|
||||
logger.info("Getting Cookbook app configuration")
|
||||
config_data = await nc_client.cookbook.get_config()
|
||||
|
||||
# Config may be empty initially, just verify we can get it
|
||||
assert isinstance(config_data, dict)
|
||||
logger.info(f"Cookbook config: {config_data}")
|
||||
|
||||
|
||||
async def test_cookbook_list_recipes(nc_client: NextcloudClient):
|
||||
"""Test listing all recipes."""
|
||||
logger.info("Listing all recipes")
|
||||
recipes = await nc_client.cookbook.list_recipes()
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes")
|
||||
|
||||
|
||||
async def test_cookbook_create_and_read_recipe(nc_client: NextcloudClient):
|
||||
"""Test creating a recipe and reading it back."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "A test recipe for integration testing",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": [
|
||||
"Mix ingredients",
|
||||
"Cook for 20 minutes",
|
||||
"Serve hot",
|
||||
],
|
||||
"recipeCategory": "Test",
|
||||
"keywords": "test,integration",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
logger.info(f"Created recipe with ID: {recipe_id}")
|
||||
|
||||
try:
|
||||
# Read the recipe back
|
||||
logger.info(f"Reading recipe ID: {recipe_id}")
|
||||
retrieved_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
assert retrieved_recipe["name"] == recipe_name
|
||||
assert (
|
||||
retrieved_recipe["description"] == "A test recipe for integration testing"
|
||||
)
|
||||
assert len(retrieved_recipe["recipeIngredient"]) == 3
|
||||
assert len(retrieved_recipe["recipeInstructions"]) == 3
|
||||
assert retrieved_recipe["recipeCategory"] == "Test"
|
||||
assert retrieved_recipe["recipeYield"] == 4
|
||||
logger.info(f"Successfully verified recipe: {recipe_name}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info(f"Successfully deleted recipe ID: {recipe_id}")
|
||||
|
||||
|
||||
async def test_cookbook_update_recipe(nc_client: NextcloudClient):
|
||||
"""Test updating a recipe."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Original description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Get the current recipe first
|
||||
current_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
# Update the recipe with all required fields
|
||||
updated_data = current_recipe.copy()
|
||||
updated_data["description"] = "Updated description"
|
||||
updated_data["recipeIngredient"] = ["100g flour", "2 eggs"]
|
||||
updated_data["recipeInstructions"] = ["Mix ingredients", "Cook"]
|
||||
updated_data["recipeCategory"] = "Updated"
|
||||
|
||||
logger.info(f"Updating recipe ID: {recipe_id}")
|
||||
updated_id = await nc_client.cookbook.update_recipe(recipe_id, updated_data)
|
||||
assert updated_id == recipe_id
|
||||
|
||||
# Verify the update
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert updated_recipe["description"] == "Updated description"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
logger.info(f"Successfully updated recipe ID: {recipe_id}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
|
||||
"""Test deleting a non-existent recipe.
|
||||
|
||||
Note: The Cookbook API may return 502 or succeed silently for non-existent IDs
|
||||
rather than 404. This test verifies the behavior."""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}")
|
||||
try:
|
||||
result = await nc_client.cookbook.delete_recipe(non_existent_id)
|
||||
logger.info(f"Delete returned: {result}")
|
||||
# API may succeed silently or return an error message
|
||||
assert isinstance(result, str)
|
||||
except HTTPStatusError as e:
|
||||
# API may return 404 or 502 for non-existent recipes
|
||||
assert e.response.status_code in [404, 502]
|
||||
logger.info(f"Delete correctly failed with {e.response.status_code}")
|
||||
|
||||
|
||||
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
|
||||
"""Test importing a recipe from a URL.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
logger.info(f"Importing recipe from nginx container: {test_url}")
|
||||
|
||||
try:
|
||||
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
|
||||
logger.info(f"Successfully imported recipe: {imported_recipe.get('name')}")
|
||||
|
||||
# Verify basic recipe structure
|
||||
assert "name" in imported_recipe
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
assert "id" in imported_recipe
|
||||
|
||||
# Verify schema.org fields were imported correctly
|
||||
assert imported_recipe.get("description")
|
||||
assert len(imported_recipe.get("recipeIngredient", [])) > 0
|
||||
assert len(imported_recipe.get("recipeInstructions", [])) > 0
|
||||
assert imported_recipe.get("recipeCategory") == "Main Course"
|
||||
assert "tofu" in imported_recipe.get("keywords", "").lower()
|
||||
|
||||
recipe_id = int(imported_recipe["id"])
|
||||
|
||||
# Verify we can read it back
|
||||
retrieved = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {recipe_id}")
|
||||
|
||||
# Clean up
|
||||
logger.info(f"Deleting imported recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info("Successfully deleted imported recipe")
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Recipe already exists - this is acceptable in tests
|
||||
logger.warning("Recipe already exists (409 conflict)")
|
||||
pytest.skip("Recipe already exists in test environment")
|
||||
elif e.response.status_code == 400:
|
||||
# URL couldn't be imported
|
||||
logger.error(
|
||||
f"Failed to import recipe from nginx container: {test_url}. "
|
||||
f"Status: {e.response.status_code}, Response: {e.response.text}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(nc_client: NextcloudClient):
|
||||
"""Test searching for recipes."""
|
||||
# Create a test recipe with unique keywords
|
||||
unique_keyword = f"testkeyword{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": f"Recipe for testing search with {unique_keyword}",
|
||||
"keywords": unique_keyword,
|
||||
"recipeIngredient": ["test ingredient"],
|
||||
"recipeInstructions": ["test instruction"],
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Search for the recipe
|
||||
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
|
||||
search_results = await nc_client.cookbook.search_recipes(unique_keyword)
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
# Should find at least our recipe
|
||||
assert len(search_results) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {recipe_id} not found in search results"
|
||||
logger.info(f"Successfully found recipe {recipe_id} in search results")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(nc_client: NextcloudClient):
|
||||
"""Test listing recipe categories."""
|
||||
logger.info("Listing recipe categories")
|
||||
categories = await nc_client.cookbook.list_categories()
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories")
|
||||
|
||||
# Each category should have name and recipe_count
|
||||
if categories:
|
||||
assert "name" in categories[0]
|
||||
assert "recipe_count" in categories[0]
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient):
|
||||
"""Test getting recipes in a specific category."""
|
||||
# Create a recipe in a test category
|
||||
unique_category = f"TestCategory{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"recipeCategory": unique_category,
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get recipes in this category
|
||||
logger.info(f"Getting recipes in category: {unique_category}")
|
||||
recipes_in_category = await nc_client.cookbook.get_recipes_in_category(
|
||||
unique_category
|
||||
)
|
||||
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in recipes_in_category)
|
||||
assert found, f"Recipe {recipe_id} not found in category {unique_category}"
|
||||
logger.info(f"Successfully found recipe in category {unique_category}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_list_keywords(nc_client: NextcloudClient):
|
||||
"""Test listing recipe keywords."""
|
||||
logger.info("Listing recipe keywords")
|
||||
keywords = await nc_client.cookbook.list_keywords()
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords")
|
||||
|
||||
# Each keyword should have name and recipe_count
|
||||
if keywords:
|
||||
assert "name" in keywords[0]
|
||||
assert "recipe_count" in keywords[0]
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
|
||||
"""Test getting recipes with specific keywords.
|
||||
|
||||
Note: The keywords filtering may require exact keyword matches and sufficient
|
||||
indexing time. This test uses a longer wait time."""
|
||||
# Create a recipe with unique keywords
|
||||
unique_keyword = f"testtag{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"keywords": f"{unique_keyword},integration",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Allow extra time for indexing
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Trigger a reindex to ensure the recipe is indexed
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get recipes with this keyword
|
||||
logger.info(f"Getting recipes with keyword: {unique_keyword}")
|
||||
recipes_with_keywords = await nc_client.cookbook.get_recipes_with_keywords(
|
||||
[unique_keyword]
|
||||
)
|
||||
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
# Keyword filtering might not find recipes immediately due to indexing
|
||||
# Log the results for debugging
|
||||
logger.info(
|
||||
f"Found {len(recipes_with_keywords)} recipes with keyword {unique_keyword}"
|
||||
)
|
||||
|
||||
if len(recipes_with_keywords) > 0:
|
||||
# Verify our recipe is in the results if any are found
|
||||
found = any(
|
||||
str(r.get("id")) == str(recipe_id) for r in recipes_with_keywords
|
||||
)
|
||||
if found:
|
||||
logger.info(f"Successfully found recipe with keyword {unique_keyword}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {recipe_id} not in keyword results, but other recipes found"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} - may be indexing delay"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_reindex(nc_client: NextcloudClient):
|
||||
"""Test triggering a reindex of recipes."""
|
||||
logger.info("Triggering recipe reindex")
|
||||
result = await nc_client.cookbook.reindex()
|
||||
|
||||
# Should return a success message
|
||||
assert isinstance(result, str)
|
||||
logger.info(f"Reindex result: {result}")
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Interactive integration tests for OAuth authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"GITHUB_ACTIONS" in os.environ,
|
||||
reason="Unable to access interactive browser in GitHub Actions",
|
||||
)
|
||||
async def test_oauth_client_with_interactive_flow(nc_oauth_client_interactive):
|
||||
"""Test that OAuth client created via interactive flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_interactive.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (interactive) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_interactive.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (interactive) successfully listed {len(notes)} notes")
|
||||
|
||||
# Test 3: Create and delete a note
|
||||
test_note = await nc_oauth_client_interactive.notes.create_note(
|
||||
title="OAuth Interactive Test Note",
|
||||
content="This note was created during OAuth interactive testing",
|
||||
)
|
||||
assert test_note is not None
|
||||
assert test_note.get("id") is not None
|
||||
note_id = test_note["id"]
|
||||
logger.info(f"OAuth client (interactive) successfully created note {note_id}")
|
||||
|
||||
# Clean up
|
||||
await nc_oauth_client_interactive.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"OAuth client (interactive) successfully deleted note {note_id}")
|
||||
@@ -19,14 +19,14 @@ async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client):
|
||||
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_playwright.capabilities()
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (Playwright) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_playwright.notes.get_all_notes()
|
||||
notes = await nc_oauth_client.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
|
||||
|
||||
+213
-549
@@ -58,6 +58,69 @@ async def wait_for_nextcloud(
|
||||
return False
|
||||
|
||||
|
||||
async def create_mcp_client_session(
|
||||
url: str,
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session with proper lifecycle management.
|
||||
|
||||
Consolidates the common pattern used by all MCP client fixtures:
|
||||
- Creates streamable HTTP client with optional OAuth token
|
||||
- Initializes MCP ClientSession
|
||||
- Handles cleanup with proper exception handling
|
||||
|
||||
Args:
|
||||
url: MCP server URL (e.g., "http://127.0.0.1:8000/mcp")
|
||||
token: Optional OAuth access token for Bearer authentication
|
||||
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
"""
|
||||
logger.info(f"Creating Streamable HTTP client for {client_name}")
|
||||
|
||||
# Prepare headers with OAuth token if provided
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
streamable_context = streamablehttp_client(url, headers=headers)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing {client_name} session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing {client_name} session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing {client_name} streamable HTTP client: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing {client_name} streamable HTTP client: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
@@ -98,102 +161,11 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
"""
|
||||
logger.info("Creating Streamable HTTP client")
|
||||
streamable_context = streamablehttp_client("http://127.0.0.1:8000/mcp")
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("MCP client session initialized successfully")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
||||
):
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client_interactive(
|
||||
interactive_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for OAuth integration tests using interactive authentication.
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
Requires manual browser login.
|
||||
|
||||
For automated testing, use nc_mcp_oauth_client fixture instead.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
|
||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Interactive)")
|
||||
|
||||
# Pass OAuth token as Bearer token in headers
|
||||
headers = {"Authorization": f"Bearer {interactive_oauth_token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("OAuth MCP client session (Interactive) initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing OAuth session (Interactive): {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing OAuth session (Interactive): {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing OAuth streamable HTTP client (Interactive): {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error closing OAuth streamable HTTP client (Interactive): {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client(
|
||||
@@ -203,54 +175,15 @@ async def nc_mcp_oauth_client(
|
||||
Fixture to create an MCP client session for OAuth integration tests using Playwright automation.
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
|
||||
This is the default OAuth MCP fixture using headless browser automation suitable for CI/CD.
|
||||
For interactive testing with manual browser login, use nc_mcp_oauth_client_interactive instead.
|
||||
Uses headless browser automation suitable for CI/CD.
|
||||
"""
|
||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)")
|
||||
|
||||
# Pass OAuth token as Bearer token in headers
|
||||
headers = {"Authorization": f"Bearer {playwright_oauth_token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("OAuth MCP client session (Playwright) initialized successfully")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp",
|
||||
token=playwright_oauth_token,
|
||||
client_name="OAuth MCP (Playwright)",
|
||||
):
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_note(nc_client: NextcloudClient):
|
||||
@@ -569,55 +502,13 @@ async def temporary_board_with_card(
|
||||
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client_interactive(
|
||||
interactive_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance using interactive OAuth authentication.
|
||||
Uses the interactive_oauth_token fixture which requires manual browser login.
|
||||
|
||||
For automated testing, use nc_oauth_client fixture instead.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
|
||||
if not all([nextcloud_host, username]):
|
||||
pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME")
|
||||
|
||||
logger.info(f"Creating OAuth NextcloudClient (Interactive) for user: {username}")
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host,
|
||||
token=interactive_oauth_token,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Verify the OAuth client works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"OAuth NextcloudClient (Interactive) initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OAuth NextcloudClient (Interactive): {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud with OAuth token: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance using automated Playwright OAuth authentication.
|
||||
This is the default OAuth fixture using headless browser automation suitable for CI/CD.
|
||||
|
||||
For interactive testing with manual browser login, use nc_oauth_client_interactive instead.
|
||||
Uses headless browser automation suitable for CI/CD.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
@@ -651,8 +542,10 @@ def oauth_callback_server():
|
||||
"""
|
||||
Fixture to create an HTTP server for OAuth callback handling.
|
||||
|
||||
Yields a tuple of (auth_state, server_url) where:
|
||||
- auth_state: A dict with {"code": None} that will be populated with the auth code
|
||||
Supports multiple concurrent OAuth flows using state parameters for correlation.
|
||||
|
||||
Yields a tuple of (auth_states, server_url) where:
|
||||
- auth_states: A dict mapping state parameter to auth code
|
||||
- server_url: The callback URL for the server (e.g., "http://localhost:8081")
|
||||
|
||||
The server automatically shuts down when the fixture is torn down.
|
||||
@@ -663,8 +556,9 @@ def oauth_callback_server():
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Use a mutable container to share state across threads
|
||||
auth_state = {"code": None}
|
||||
# Use a dict to store auth codes keyed by state parameter
|
||||
# This allows multiple concurrent OAuth flows
|
||||
auth_states = {}
|
||||
httpd = None
|
||||
server_thread = None
|
||||
|
||||
@@ -674,26 +568,27 @@ def oauth_callback_server():
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
# Ignore subsequent requests if we already have a code
|
||||
# (this is a session-scoped fixture, so only process the first auth code)
|
||||
if auth_state["code"] is not None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<html><body><h1>Authentication already completed</h1></body></html>"
|
||||
)
|
||||
return
|
||||
|
||||
# Parse the callback request
|
||||
parsed_path = urlparse(self.path)
|
||||
query = parse_qs(parsed_path.query)
|
||||
code = query.get("code", [None])[0]
|
||||
state = query.get("state", [None])[0]
|
||||
|
||||
# Only process if we have a valid code
|
||||
if code:
|
||||
auth_state["code"] = code
|
||||
logger.info(f"OAuth callback received. Code: {code[:20]}...")
|
||||
# Store code keyed by state parameter for correlation
|
||||
if state:
|
||||
auth_states[state] = code
|
||||
logger.info(
|
||||
f"OAuth callback received for state={state[:16]}... Code: {code[:20]}..."
|
||||
)
|
||||
else:
|
||||
# Fallback for flows without state parameter (legacy interactive flow)
|
||||
auth_states["_default"] = code
|
||||
logger.info(
|
||||
f"OAuth callback received (no state). Code: {code[:20]}..."
|
||||
)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
@@ -714,8 +609,8 @@ def oauth_callback_server():
|
||||
server_thread.start()
|
||||
logger.info("OAuth callback server started on http://localhost:8081")
|
||||
|
||||
# Yield the auth state and server URL
|
||||
yield auth_state, "http://localhost:8081"
|
||||
# Yield the auth states dict and server URL
|
||||
yield auth_states, "http://localhost:8081"
|
||||
|
||||
finally:
|
||||
# Clean up the server
|
||||
@@ -731,92 +626,15 @@ def oauth_callback_server():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def interactive_oauth_token(oauth_callback_server) -> str:
|
||||
"""
|
||||
Fixture to obtain an OAuth access token for integration tests.
|
||||
|
||||
This uses the interactive OAuth flow to get a token.
|
||||
Depends on oauth_callback_server fixture for HTTP callback handling.
|
||||
|
||||
Automatically skips when running in GitHub Actions CI.
|
||||
"""
|
||||
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
||||
|
||||
# Unpack the server fixture
|
||||
auth_state, callback_url = oauth_callback_server
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await http_client.get(discovery_url)
|
||||
oidc_config = discovery_response.json()
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=".nextcloud_oauth_shared_test_client.json",
|
||||
redirect_uris=[callback_url],
|
||||
force_register=False, # Reuse existing credentials if valid
|
||||
)
|
||||
|
||||
# First, open Nextcloud login page to establish session
|
||||
login_url = f"{nextcloud_host}/login"
|
||||
logger.info(f"Please log in to Nextcloud at: {login_url}")
|
||||
logger.info(
|
||||
"After logging in, the OAuth authorization will proceed automatically"
|
||||
)
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri={callback_url}&scope=openid%20profile%20email"
|
||||
|
||||
# Open authorization URL in browser
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
# Wait for auth code with timeout
|
||||
timeout = 120 # 2 minutes
|
||||
start_time = time.time()
|
||||
while not auth_state["code"]:
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError("OAuth authorization timed out after 2 minutes")
|
||||
logger.info("Waiting for OAuth authorization...")
|
||||
time.sleep(1)
|
||||
|
||||
auth_code = auth_state["code"]
|
||||
logger.info("Received authorization code, exchanging for token...")
|
||||
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"Token response: {token_response.text}")
|
||||
token_data = token_response.json()
|
||||
logger.debug(f"Token data: {token_data}")
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def shared_oauth_client_credentials():
|
||||
async def shared_oauth_client_credentials(oauth_callback_server):
|
||||
"""
|
||||
Fixture to obtain shared OAuth client credentials that will be reused for all users.
|
||||
|
||||
This registers a single OAuth client with Nextcloud that matches the MCP server's
|
||||
registration, allowing all test users to authenticate using the same client_id/secret.
|
||||
|
||||
Now uses the real OAuth callback server for reliable token acquisition.
|
||||
|
||||
Returns:
|
||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||
"""
|
||||
@@ -826,7 +644,11 @@ async def shared_oauth_client_credentials():
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST")
|
||||
|
||||
# Get callback URL from the real callback server
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
logger.info("Setting up shared OAuth client credentials for all test users...")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
# OIDC Discovery
|
||||
@@ -842,9 +664,6 @@ async def shared_oauth_client_credentials():
|
||||
if not all([token_endpoint, registration_endpoint, authorization_endpoint]):
|
||||
raise ValueError("OIDC discovery missing required endpoints")
|
||||
|
||||
# Use callback URL that won't actually be used (we extract code from browser URL)
|
||||
callback_url = "http://localhost:9999/oauth/callback"
|
||||
|
||||
# Register or load shared OAuth client (matches MCP server registration)
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
@@ -852,7 +671,6 @@ async def shared_oauth_client_credentials():
|
||||
storage_path=".nextcloud_oauth_shared_test_client.json",
|
||||
client_name="Nextcloud MCP Server - Shared Test Client",
|
||||
redirect_uris=[callback_url],
|
||||
force_register=False, # Reuse existing credentials if valid
|
||||
)
|
||||
|
||||
logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...")
|
||||
@@ -868,7 +686,9 @@ async def shared_oauth_client_credentials():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> str:
|
||||
async def playwright_oauth_token(
|
||||
browser, shared_oauth_client_credentials, oauth_callback_server
|
||||
) -> str:
|
||||
"""
|
||||
Fixture to obtain an OAuth access token using Playwright headless browser automation.
|
||||
|
||||
@@ -877,7 +697,7 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
|
||||
2. Navigating to authorization URL in headless browser
|
||||
3. Programmatically filling in login form
|
||||
4. Handling OAuth consent
|
||||
5. Extracting auth code from redirect
|
||||
5. Waiting for callback server to receive auth code (NEW: using real callback server!)
|
||||
6. Exchanging code for access token
|
||||
|
||||
Environment variables required:
|
||||
@@ -890,7 +710,9 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
|
||||
- Browser fixture provided by pytest-playwright-asyncio
|
||||
- See: https://playwright.dev/python/docs/test-runners
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
@@ -901,6 +723,9 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
|
||||
"Playwright OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Get auth_states dict from callback server
|
||||
auth_states, _ = oauth_callback_server
|
||||
|
||||
# Unpack shared client credentials
|
||||
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
|
||||
shared_oauth_client_credentials
|
||||
@@ -908,13 +733,19 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
|
||||
|
||||
logger.info(f"Starting Playwright-based OAuth flow for {username}...")
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
logger.info(f"Using real callback server at: {callback_url}")
|
||||
|
||||
# Construct authorization URL
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state: {state[:16]}...")
|
||||
|
||||
# Construct authorization URL with state parameter
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={callback_url}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope=openid%20profile%20email"
|
||||
)
|
||||
|
||||
@@ -971,33 +802,24 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization button found or already authorized: {e}")
|
||||
|
||||
# Wait for redirect to callback URL (which will fail to load, but we just need the URL)
|
||||
try:
|
||||
# The redirect might fail since localhost:9999 isn't actually running
|
||||
# But we can still extract the code from the URL
|
||||
await page.wait_for_url(f"{callback_url}*", timeout=10000)
|
||||
except Exception as e:
|
||||
# Expected - the callback URL won't load, but we should have the URL
|
||||
logger.debug(f"Callback redirect (expected to fail): {e}")
|
||||
# Wait for callback server to receive the auth code
|
||||
# Browser will be redirected to localhost:8081 which will capture the code
|
||||
logger.info("Waiting for callback server to receive auth code...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
# Take a screenshot for debugging
|
||||
screenshot_path = "/tmp/playwright_oauth_error.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Screenshot saved to {screenshot_path}")
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Extract auth code from URL
|
||||
final_url = page.url
|
||||
logger.debug(f"Final URL: {final_url}")
|
||||
|
||||
parsed_url = urlparse(final_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
auth_code = query_params.get("code", [None])[0]
|
||||
|
||||
if not auth_code:
|
||||
# Take a screenshot for debugging
|
||||
screenshot_path = "/tmp/playwright_oauth_error.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Screenshot saved to {screenshot_path}")
|
||||
raise ValueError(
|
||||
f"No authorization code found in redirect URL: {final_url}"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully extracted authorization code: {auth_code[:20]}...")
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Successfully received authorization code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
@@ -1027,105 +849,6 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
|
||||
return access_token
|
||||
|
||||
|
||||
# Alternative fixtures using Playwright token (for automated/CI testing)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_oauth_client_playwright(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance using automated Playwright OAuth authentication.
|
||||
This fixture uses headless browser automation and is suitable for CI/CD pipelines.
|
||||
|
||||
For interactive testing, use nc_oauth_client fixture instead.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
|
||||
if not all([nextcloud_host, username]):
|
||||
pytest.skip(
|
||||
"Playwright OAuth client fixture requires NEXTCLOUD_HOST and USERNAME"
|
||||
)
|
||||
|
||||
logger.info(f"Creating OAuth NextcloudClient (Playwright) for user: {username}")
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=nextcloud_host,
|
||||
token=playwright_oauth_token,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Verify the OAuth client works
|
||||
try:
|
||||
await client.capabilities()
|
||||
logger.info(
|
||||
"OAuth NextcloudClient (Playwright) initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Playwright OAuth NextcloudClient: {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud with Playwright OAuth token: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client_playwright(
|
||||
playwright_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for OAuth integration tests using Playwright automation.
|
||||
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
|
||||
|
||||
This fixture uses headless browser automation and is suitable for CI/CD pipelines.
|
||||
For interactive testing, use nc_mcp_oauth_client fixture instead.
|
||||
"""
|
||||
logger.info("Creating Streamable HTTP client for OAuth MCP server (Playwright)")
|
||||
|
||||
# Pass OAuth token as Bearer token in headers
|
||||
headers = {"Authorization": f"Bearer {playwright_oauth_token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("OAuth MCP client session (Playwright) initialized successfully")
|
||||
|
||||
yield session
|
||||
|
||||
finally:
|
||||
# Clean up in reverse order, ignoring task scope issues
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing Playwright OAuth session: {e}")
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error closing Playwright OAuth streamable HTTP client: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def test_users_setup(nc_client: NextcloudClient):
|
||||
"""
|
||||
@@ -1236,23 +959,31 @@ async def test_users_setup(nc_client: NextcloudClient):
|
||||
|
||||
|
||||
async def _get_oauth_token_for_user(
|
||||
browser, shared_oauth_client_credentials, username: str, password: str
|
||||
browser,
|
||||
shared_oauth_client_credentials,
|
||||
auth_states,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> str:
|
||||
"""
|
||||
Helper function to get OAuth access token for a user via Playwright.
|
||||
|
||||
Uses shared OAuth client credentials to authenticate multiple users with the same client.
|
||||
Now uses real callback server with state parameters for reliable token acquisition.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
shared_oauth_client_credentials: Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||
auth_states: Dict mapping state parameters to auth codes (from callback server)
|
||||
username: Username to authenticate as
|
||||
password: Password for the user
|
||||
|
||||
Returns:
|
||||
OAuth access token string
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
|
||||
@@ -1267,14 +998,17 @@ async def _get_oauth_token_for_user(
|
||||
logger.info(f"Getting OAuth token for user: {username}...")
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
|
||||
# Construct authorization URL with properly encoded redirect_uri
|
||||
from urllib.parse import quote
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state for {username}: {state[:16]}...")
|
||||
|
||||
# Construct authorization URL with state parameter
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope=openid%20profile%20email"
|
||||
)
|
||||
|
||||
@@ -1311,22 +1045,25 @@ async def _get_oauth_token_for_user(
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization needed for {username}: {e}")
|
||||
|
||||
# Wait for redirect and extract auth code
|
||||
try:
|
||||
await page.wait_for_url(f"{callback_url}*", timeout=30000)
|
||||
except Exception:
|
||||
pass # Expected - callback won't load
|
||||
|
||||
final_url = page.url
|
||||
parsed_url = urlparse(final_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
auth_code = query_params.get("code", [None])[0]
|
||||
|
||||
if not auth_code:
|
||||
raise ValueError(
|
||||
f"No authorization code found for {username} in URL: {final_url}"
|
||||
)
|
||||
# Wait for callback server to receive the auth code
|
||||
# Browser will be redirected to localhost:8081 which will capture the code
|
||||
logger.info(
|
||||
f"Waiting for callback server to receive auth code for {username}..."
|
||||
)
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/playwright_oauth_timeout_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Screenshot saved to {screenshot_path}")
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback for {username} (state={state[:16]}...)"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code for {username}: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
@@ -1360,7 +1097,7 @@ async def _get_oauth_token_for_user(
|
||||
# Parallel token retrieval fixture - fetches all OAuth tokens concurrently
|
||||
@pytest.fixture(scope="session")
|
||||
async def all_oauth_tokens(
|
||||
browser, shared_oauth_client_credentials, test_users_setup
|
||||
browser, shared_oauth_client_credentials, test_users_setup, oauth_callback_server
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Fetch OAuth tokens for all test users in parallel for speed.
|
||||
@@ -1368,26 +1105,34 @@ async def all_oauth_tokens(
|
||||
Returns a dict mapping username to OAuth access token.
|
||||
This is significantly faster than fetching tokens sequentially.
|
||||
|
||||
Note: We add a small stagger between starting each flow to avoid
|
||||
race conditions in Nextcloud's OAuth session handling.
|
||||
Now uses the real callback server with state parameters for reliable
|
||||
concurrent token acquisition without race conditions.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
# Get auth_states dict from callback server
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
start_time = time.time()
|
||||
logger.info("Fetching OAuth tokens for all users in parallel...")
|
||||
logger.info(f"Using callback server at {callback_url} with state-based correlation")
|
||||
|
||||
async def get_token_with_delay(username: str, config: dict, delay: float):
|
||||
"""Get token for a user after a small delay to stagger requests."""
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
return await _get_oauth_token_for_user(
|
||||
browser, shared_oauth_client_credentials, username, config["password"]
|
||||
browser,
|
||||
shared_oauth_client_credentials,
|
||||
auth_states,
|
||||
username,
|
||||
config["password"],
|
||||
)
|
||||
|
||||
# Create tasks for all users with staggered starts (2.0s apart)
|
||||
# Create tasks for all users with staggered starts (0.5s apart)
|
||||
tasks = {
|
||||
username: get_token_with_delay(username, config, (idx + 1) * 2.0)
|
||||
username: get_token_with_delay(username, config, idx * 0.5)
|
||||
for idx, (username, config) in enumerate(test_users_setup.items())
|
||||
}
|
||||
|
||||
@@ -1436,133 +1181,52 @@ async def diana_oauth_token(all_oauth_tokens) -> str:
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def alice_mcp_client(alice_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
||||
async def alice_mcp_client(
|
||||
alice_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as alice (owner role)."""
|
||||
token = alice_oauth_token
|
||||
|
||||
# Create MCP client session with proper lifecycle management
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("Alice MCP client session initialized")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp",
|
||||
token=alice_oauth_token,
|
||||
client_name="Alice MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
finally:
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing alice session: {e}")
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing alice streamable context: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def bob_mcp_client(bob_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
||||
async def bob_mcp_client(bob_oauth_token: str) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as bob (viewer role)."""
|
||||
token = bob_oauth_token
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("Bob MCP client session initialized")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp", token=bob_oauth_token, client_name="Bob MCP"
|
||||
):
|
||||
yield session
|
||||
|
||||
finally:
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing bob session: {e}")
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing bob streamable context: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def charlie_mcp_client(charlie_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
||||
async def charlie_mcp_client(
|
||||
charlie_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
||||
token = charlie_oauth_token
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("Charlie MCP client session initialized")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp",
|
||||
token=charlie_oauth_token,
|
||||
client_name="Charlie MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
finally:
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing charlie session: {e}")
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing charlie streamable context: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def diana_mcp_client(diana_oauth_token) -> AsyncGenerator[ClientSession, Any]:
|
||||
async def diana_mcp_client(
|
||||
diana_oauth_token: str,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client authenticated as diana (no-access role)."""
|
||||
token = diana_oauth_token
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
streamable_context = streamablehttp_client(
|
||||
"http://127.0.0.1:8001/mcp", headers=headers
|
||||
)
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("Diana MCP client session initialized")
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://127.0.0.1:8001/mcp",
|
||||
token=diana_oauth_token,
|
||||
client_name="Diana MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
finally:
|
||||
if session_context is not None:
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing diana session: {e}")
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing diana streamable context: {e}")
|
||||
|
||||
|
||||
# Test user/group fixtures for clean test isolation
|
||||
@pytest.fixture
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type text/html;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri.html =404;
|
||||
}
|
||||
|
||||
# Serve test_recipe.html at /black-pepper-tofu
|
||||
location = /black-pepper-tofu {
|
||||
root /usr/share/nginx/html;
|
||||
try_files /test_recipe.html =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Black Pepper Tofu Recipe - Test Recipe</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Black Pepper Tofu",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Yotam Ottolenghi"
|
||||
},
|
||||
"datePublished": "2024-01-15",
|
||||
"description": "A flavorful black pepper tofu dish with aromatic spices and crispy texture. Inspired by Yotam Ottolenghi's signature style.",
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
"recipeYield": "4",
|
||||
"recipeCategory": "Main Course",
|
||||
"recipeCuisine": "Asian Fusion",
|
||||
"keywords": "tofu, black pepper, vegetarian, vegan, ottolenghi",
|
||||
"image": "https://example.com/black-pepper-tofu.jpg",
|
||||
"recipeIngredient": [
|
||||
"400g firm tofu, pressed and cubed",
|
||||
"2 tablespoons black peppercorns, coarsely ground",
|
||||
"3 tablespoons soy sauce",
|
||||
"2 tablespoons rice vinegar",
|
||||
"1 tablespoon maple syrup",
|
||||
"2 tablespoons cornstarch",
|
||||
"3 tablespoons vegetable oil",
|
||||
"4 cloves garlic, minced",
|
||||
"1 tablespoon fresh ginger, grated",
|
||||
"2 spring onions, sliced",
|
||||
"1 red bell pepper, sliced",
|
||||
"Sesame seeds for garnish"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Toss tofu cubes with cornstarch until evenly coated."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add bell pepper and cook for 2-3 minutes until slightly softened."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles."
|
||||
}
|
||||
],
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "280 kcal",
|
||||
"proteinContent": "18 g",
|
||||
"fatContent": "16 g",
|
||||
"carbohydrateContent": "18 g",
|
||||
"fiberContent": "3 g",
|
||||
"servingSize": "1 serving"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Black Pepper Tofu</h1>
|
||||
<p class="author">By Yotam Ottolenghi</p>
|
||||
<p class="description">
|
||||
A flavorful black pepper tofu dish with aromatic spices and crispy texture.
|
||||
Inspired by Yotam Ottolenghi's signature style.
|
||||
</p>
|
||||
|
||||
<div class="recipe-meta">
|
||||
<p><strong>Prep Time:</strong> 15 minutes</p>
|
||||
<p><strong>Cook Time:</strong> 20 minutes</p>
|
||||
<p><strong>Total Time:</strong> 35 minutes</p>
|
||||
<p><strong>Servings:</strong> 4</p>
|
||||
</div>
|
||||
|
||||
<h2>Ingredients</h2>
|
||||
<ul>
|
||||
<li>400g firm tofu, pressed and cubed</li>
|
||||
<li>2 tablespoons black peppercorns, coarsely ground</li>
|
||||
<li>3 tablespoons soy sauce</li>
|
||||
<li>2 tablespoons rice vinegar</li>
|
||||
<li>1 tablespoon maple syrup</li>
|
||||
<li>2 tablespoons cornstarch</li>
|
||||
<li>3 tablespoons vegetable oil</li>
|
||||
<li>4 cloves garlic, minced</li>
|
||||
<li>1 tablespoon fresh ginger, grated</li>
|
||||
<li>2 spring onions, sliced</li>
|
||||
<li>1 red bell pepper, sliced</li>
|
||||
<li>Sesame seeds for garnish</li>
|
||||
</ul>
|
||||
|
||||
<h2>Instructions</h2>
|
||||
<ol>
|
||||
<li>Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.</li>
|
||||
<li>Toss tofu cubes with cornstarch until evenly coated.</li>
|
||||
<li>Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside.</li>
|
||||
<li>In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.</li>
|
||||
<li>Add bell pepper and cook for 2-3 minutes until slightly softened.</li>
|
||||
<li>Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.</li>
|
||||
<li>Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.</li>
|
||||
<li>Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Nutrition Information</h2>
|
||||
<p>Per serving: 280 calories, 18g protein, 16g fat, 18g carbohydrates, 3g fiber</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,554 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_mcp_cookbook_create_and_read_recipe(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test creating and reading a recipe via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
recipe_name = f"MCP Test Recipe {unique_suffix}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "A test recipe created via MCP tools",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": ["Mix ingredients", "Cook for 20 minutes", "Serve hot"],
|
||||
"recipeCategory": "MCPTesting",
|
||||
"keywords": f"mcp,testing,{unique_suffix}",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via MCP
|
||||
logger.info(f"Creating recipe via MCP: {recipe_name}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_create_recipe",
|
||||
{
|
||||
"name": recipe_name,
|
||||
"description": recipe_data["description"],
|
||||
"ingredients": recipe_data["recipeIngredient"],
|
||||
"instructions": recipe_data["recipeInstructions"],
|
||||
"category": recipe_data["recipeCategory"],
|
||||
"keywords": recipe_data["keywords"],
|
||||
"recipe_yield": recipe_data["recipeYield"],
|
||||
"prep_time": recipe_data["prepTime"],
|
||||
"cook_time": recipe_data["cookTime"],
|
||||
"total_time": recipe_data["totalTime"],
|
||||
},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP recipe creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
create_response = json.loads(create_result.content[0].text)
|
||||
created_recipe_id = create_response["id"]
|
||||
logger.info(f"Recipe created via MCP with ID: {created_recipe_id}")
|
||||
|
||||
# 2. Verify creation via direct NextcloudClient
|
||||
direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert direct_recipe["name"] == recipe_name
|
||||
assert direct_recipe["description"] == "A test recipe created via MCP tools"
|
||||
assert len(direct_recipe["recipeIngredient"]) == 3
|
||||
assert len(direct_recipe["recipeInstructions"]) == 3
|
||||
assert direct_recipe["recipeCategory"] == "MCPTesting"
|
||||
|
||||
# 3. Read recipe via MCP
|
||||
logger.info(f"Reading recipe via MCP: {created_recipe_id}")
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipe", {"recipe_id": created_recipe_id}
|
||||
)
|
||||
|
||||
assert read_result.isError is False, (
|
||||
f"MCP recipe read failed: {read_result.content}"
|
||||
)
|
||||
|
||||
read_recipe = json.loads(read_result.content[0].text)
|
||||
assert read_recipe["name"] == recipe_name
|
||||
assert read_recipe["description"] == "A test recipe created via MCP tools"
|
||||
assert len(read_recipe["recipeIngredient"]) == 3
|
||||
|
||||
logger.info(f"Successfully verified recipe {created_recipe_id} via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_update_recipe(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test updating a recipe via MCP tools."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
recipe_name = f"MCP Update Test {unique_suffix}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Original description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Update recipe via MCP (tool handles fetching current recipe internally)
|
||||
logger.info(f"Updating recipe via MCP: {created_recipe_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_update_recipe",
|
||||
{
|
||||
"recipe_id": created_recipe_id,
|
||||
"description": "Updated via MCP",
|
||||
"ingredients": ["100g flour", "2 eggs"],
|
||||
"instructions": ["Mix ingredients", "Cook"],
|
||||
"category": "Updated",
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP recipe update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 4. Verify update via direct NextcloudClient
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert updated_recipe["description"] == "Updated via MCP"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
|
||||
logger.info(f"Successfully updated recipe {created_recipe_id} via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_delete_recipe(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test deleting a recipe via MCP tools."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
recipe_name = f"MCP Delete Test {unique_suffix}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Recipe to be deleted",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for delete test: {recipe_name}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Delete recipe via MCP
|
||||
logger.info(f"Deleting recipe via MCP: {created_recipe_id}")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id}
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, (
|
||||
f"MCP recipe deletion failed: {delete_result.content}"
|
||||
)
|
||||
|
||||
# 3. Verify deletion via direct NextcloudClient
|
||||
try:
|
||||
await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
pytest.fail("Recipe should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - recipe should be deleted
|
||||
logger.info(f"Successfully verified recipe {created_recipe_id} was deleted")
|
||||
created_recipe_id = None # Mark as cleaned up
|
||||
|
||||
finally:
|
||||
# Cleanup in case of test failure
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_import_recipe_from_url(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""Test importing a recipe from a URL via MCP tools.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Import recipe via MCP
|
||||
logger.info(f"Importing recipe from nginx container via MCP: {test_url}")
|
||||
import_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_import_recipe", {"url": test_url}
|
||||
)
|
||||
|
||||
assert import_result.isError is False, (
|
||||
f"MCP recipe import failed: {import_result.content}"
|
||||
)
|
||||
|
||||
import_response = json.loads(import_result.content[0].text)
|
||||
created_recipe_id = int(import_response["recipe_id"])
|
||||
imported_recipe = import_response["recipe"]
|
||||
|
||||
logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}")
|
||||
|
||||
# 2. Verify basic recipe structure
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
assert imported_recipe.get("description")
|
||||
assert len(imported_recipe.get("recipeIngredient", [])) > 0
|
||||
assert len(imported_recipe.get("recipeInstructions", [])) > 0
|
||||
assert imported_recipe.get("recipeCategory") == "Main Course"
|
||||
assert "tofu" in imported_recipe.get("keywords", "").lower()
|
||||
|
||||
# 3. Verify we can read it back via direct NextcloudClient
|
||||
retrieved = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {created_recipe_id}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up imported recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup imported recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_search_recipes(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test searching recipes via MCP tools."""
|
||||
|
||||
unique_keyword = f"mcptestkeyword{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"MCP Search Test {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": f"Recipe for testing MCP search with {unique_keyword}",
|
||||
"keywords": unique_keyword,
|
||||
"recipeIngredient": ["test ingredient"],
|
||||
"recipeInstructions": ["test instruction"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. Search for the recipe via MCP
|
||||
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_search_recipes", {"query": unique_keyword}
|
||||
)
|
||||
|
||||
assert search_result.isError is False, (
|
||||
f"MCP recipe search failed: {search_result.content}"
|
||||
)
|
||||
|
||||
search_response = json.loads(search_result.content[0].text)
|
||||
search_results = search_response["recipes"]
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
assert len(search_results) > 0
|
||||
|
||||
# 4. Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {created_recipe_id} not found in search results"
|
||||
logger.info(
|
||||
f"Successfully found recipe {created_recipe_id} in MCP search results"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_list_recipes(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test listing all recipes via MCP tools."""
|
||||
|
||||
logger.info("Listing all recipes via MCP")
|
||||
list_result = await nc_mcp_client.call_tool("nc_cookbook_list_recipes", {})
|
||||
|
||||
assert list_result.isError is False, (
|
||||
f"MCP list recipes failed: {list_result.content}"
|
||||
)
|
||||
|
||||
list_response = json.loads(list_result.content[0].text)
|
||||
recipes = list_response["recipes"]
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes via MCP")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_categories_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test category listing and filtering via MCP tools."""
|
||||
|
||||
unique_category = f"MCPTestCategory{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"MCP Category Test {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"recipeCategory": unique_category,
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe in test category
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. List categories via MCP
|
||||
logger.info("Listing categories via MCP")
|
||||
categories_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_list_categories", {}
|
||||
)
|
||||
|
||||
assert categories_result.isError is False, (
|
||||
f"MCP list categories failed: {categories_result.content}"
|
||||
)
|
||||
|
||||
categories_response = json.loads(categories_result.content[0].text)
|
||||
categories = categories_response["categories"]
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories via MCP")
|
||||
|
||||
# 4. Get recipes in this category via MCP
|
||||
logger.info(f"Getting recipes in category via MCP: {unique_category}")
|
||||
category_recipes_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipes_in_category", {"category": unique_category}
|
||||
)
|
||||
|
||||
assert category_recipes_result.isError is False, (
|
||||
f"MCP get recipes in category failed: {category_recipes_result.content}"
|
||||
)
|
||||
|
||||
category_recipes_response = json.loads(category_recipes_result.content[0].text)
|
||||
recipes_in_category = category_recipes_response["recipes"]
|
||||
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) > 0
|
||||
|
||||
# 5. Verify our recipe is in the results
|
||||
found = any(
|
||||
str(r.get("id")) == str(created_recipe_id) for r in recipes_in_category
|
||||
)
|
||||
assert found, (
|
||||
f"Recipe {created_recipe_id} not found in category {unique_category}"
|
||||
)
|
||||
logger.info(f"Successfully found recipe in category {unique_category} via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_keywords_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test keyword listing and filtering via MCP tools."""
|
||||
|
||||
unique_keyword = f"mcptesttag{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"MCP Keyword Test {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"keywords": f"{unique_keyword},mcptesting",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe with test keywords
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow extra time for indexing and trigger reindex
|
||||
await asyncio.sleep(3)
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. List keywords via MCP
|
||||
logger.info("Listing keywords via MCP")
|
||||
keywords_result = await nc_mcp_client.call_tool("nc_cookbook_list_keywords", {})
|
||||
|
||||
assert keywords_result.isError is False, (
|
||||
f"MCP list keywords failed: {keywords_result.content}"
|
||||
)
|
||||
|
||||
keywords_response = json.loads(keywords_result.content[0].text)
|
||||
keywords = keywords_response["keywords"]
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords via MCP")
|
||||
|
||||
# 4. Get recipes with this keyword via MCP
|
||||
logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}")
|
||||
keyword_recipes_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]}
|
||||
)
|
||||
|
||||
assert keyword_recipes_result.isError is False, (
|
||||
f"MCP get recipes with keywords failed: {keyword_recipes_result.content}"
|
||||
)
|
||||
|
||||
keyword_recipes_response = json.loads(keyword_recipes_result.content[0].text)
|
||||
recipes_with_keywords = keyword_recipes_response["recipes"]
|
||||
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
|
||||
# Keyword filtering might not find recipes immediately due to indexing
|
||||
if len(recipes_with_keywords) > 0:
|
||||
# Verify our recipe is in the results if any are found
|
||||
found = any(
|
||||
str(r.get("id")) == str(created_recipe_id)
|
||||
for r in recipes_with_keywords
|
||||
)
|
||||
if found:
|
||||
logger.info(
|
||||
f"Successfully found recipe with keyword {unique_keyword} via MCP"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_config_and_version(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test getting Cookbook configuration and version via MCP resources."""
|
||||
|
||||
# 1. Get version via MCP resource
|
||||
logger.info("Getting Cookbook version via MCP resource")
|
||||
version_result = await nc_mcp_client.read_resource("cookbook://version")
|
||||
|
||||
assert len(version_result.contents) > 0
|
||||
version_response = json.loads(version_result.contents[0].text)
|
||||
assert "cookbook_version" in version_response
|
||||
assert "api_version" in version_response
|
||||
logger.info(f"Cookbook version from MCP: {version_response}")
|
||||
|
||||
# 2. Verify version via direct NextcloudClient
|
||||
direct_version = await nc_client.cookbook.get_version()
|
||||
assert direct_version["cookbook_version"] == version_response["cookbook_version"]
|
||||
assert (
|
||||
direct_version["api_version"]["epoch"]
|
||||
== version_response["api_version"]["epoch"]
|
||||
)
|
||||
|
||||
# 3. Get config via MCP resource
|
||||
logger.info("Getting Cookbook config via MCP resource")
|
||||
config_result = await nc_mcp_client.read_resource("cookbook://config")
|
||||
|
||||
assert len(config_result.contents) > 0
|
||||
config_response = json.loads(config_result.contents[0].text)
|
||||
assert isinstance(config_response, dict)
|
||||
logger.info(f"Cookbook config from MCP: {config_response}")
|
||||
|
||||
# 4. Verify config via direct NextcloudClient
|
||||
direct_config = await nc_client.cookbook.get_config()
|
||||
# Both should be dicts - exact match may vary based on config
|
||||
assert isinstance(config_response, dict)
|
||||
assert isinstance(direct_config, dict)
|
||||
|
||||
logger.info("Successfully verified Cookbook version and config via MCP")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_reindex(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test triggering a recipe reindex via MCP tools."""
|
||||
|
||||
logger.info("Triggering recipe reindex via MCP")
|
||||
reindex_result = await nc_mcp_client.call_tool("nc_cookbook_reindex", {})
|
||||
|
||||
assert reindex_result.isError is False, (
|
||||
f"MCP reindex failed: {reindex_result.content}"
|
||||
)
|
||||
|
||||
reindex_response = json.loads(reindex_result.content[0].text)
|
||||
assert isinstance(reindex_response["message"], str)
|
||||
logger.info(f"Reindex result from MCP: {reindex_response['message']}")
|
||||
@@ -52,6 +52,19 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
"deck_create_board",
|
||||
"nc_cookbook_import_recipe",
|
||||
"nc_cookbook_list_recipes",
|
||||
"nc_cookbook_get_recipe",
|
||||
"nc_cookbook_create_recipe",
|
||||
"nc_cookbook_update_recipe",
|
||||
"nc_cookbook_delete_recipe",
|
||||
"nc_cookbook_search_recipes",
|
||||
"nc_cookbook_list_categories",
|
||||
"nc_cookbook_get_recipes_in_category",
|
||||
"nc_cookbook_list_keywords",
|
||||
"nc_cookbook_get_recipes_with_keywords",
|
||||
"nc_cookbook_set_config",
|
||||
"nc_cookbook_reindex",
|
||||
]
|
||||
|
||||
for expected_tool in expected_tools:
|
||||
@@ -85,7 +98,13 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
|
||||
expected_resources = [
|
||||
"nc://capabilities",
|
||||
"notes://settings",
|
||||
"nc://Deck/boards",
|
||||
"cookbook://version",
|
||||
"cookbook://config",
|
||||
]
|
||||
|
||||
for expected_resource in expected_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
|
||||
@@ -38,11 +38,11 @@ async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright):
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client_playwright.call_tool(
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
|
||||
@@ -593,7 +593,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.17.0"
|
||||
version = "1.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -608,9 +608,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/79/5724a540df19e192e8606c543cdcf162de8eb435077520cca150f7365ec0/mcp-1.17.0.tar.gz", hash = "sha256:1b57fabf3203240ccc48e39859faf3ae1ccb0b571ff798bbedae800c73c6df90", size = 477951, upload-time = "2025-10-10T12:16:44.519Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/72/3751feae343a5ad07959df713907b5c3fbaed269d697a14b0c449080cf2e/mcp-1.17.0-py3-none-any.whl", hash = "sha256:0660ef275cada7a545af154db3082f176cf1d2681d5e35ae63e014faf0a35d40", size = 167737, upload-time = "2025-10-10T12:16:42.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -630,7 +630,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -659,8 +659,8 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.17,<1.18" },
|
||||
{ name = "pillow", specifier = ">=11.2.1,<12.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" },
|
||||
{ name = "pillow", specifier = ">=12.0.0,<12.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.4" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
]
|
||||
@@ -709,86 +709,89 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.3.0"
|
||||
version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user