Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cf5f2a95a | |||
| 1cc65f0160 | |||
| 9b00530e8e | |||
| 938376425b | |||
| 0484167a22 | |||
| 84ad1958af | |||
| fa002296ff | |||
| 464ff2c8b2 | |||
| 0804ff8d17 | |||
| 4f7023a16e | |||
| 8f6656c546 | |||
| 741c58d9a3 | |||
| e7b79d0316 | |||
| 0e4cc8e56f | |||
| 16da7a9a76 | |||
| 520e515f2b | |||
| fd6ce7b294 | |||
| 8063059f5f | |||
| 20c5046b20 | |||
| 68126640d8 | |||
| af617e3869 | |||
| 04e5f7beca | |||
| 6ed1efab24 | |||
| cffa002364 | |||
| 951a7095b2 | |||
| ee31f33038 | |||
| 0fdbfae198 | |||
| 315f918d88 | |||
| 96a8491a4c | |||
| 0a311766f2 | |||
| d28c249f8d | |||
| ab6cac8799 | |||
| 7127b9953f | |||
| 49c9af3c76 | |||
| 823151f42e | |||
| 2bbd56e1cd | |||
| 8a36a120a7 | |||
| 9df8cc937d | |||
| 325dcdf654 | |||
| 945eb1eb4e | |||
| 088343d003 | |||
| 94d553985f | |||
| 982dbd18ca | |||
| 054fa38e3a | |||
| 3836534205 | |||
| f852a18b12 | |||
| 0450c5cc52 | |||
| f48fd0be60 | |||
| ee29194bc9 | |||
| fc32fa2852 | |||
| b7d6548741 | |||
| a9ffd49815 | |||
| 538f861414 | |||
| b784651f7f | |||
| 6f0baf5fca | |||
| 664254ed95 | |||
| b976494ca2 | |||
| 061f667e00 | |||
| 3319c35798 | |||
| 52c9293c37 | |||
| af6863a764 | |||
| 77181f7c6f | |||
| 61f3beac01 | |||
| 49aaf24363 | |||
| 4edd31ee28 | |||
| 9ae2a0fc6f | |||
| 8386644dfd | |||
| 1dfdad5fad | |||
| 72cb62a101 | |||
| 21fc55320b | |||
| ad3e288203 | |||
| 0a97357a9c | |||
| 70f01bf40a | |||
| 37b1057d2a | |||
| ad95140416 | |||
| 73fb56f73d | |||
| 9cc5300aa8 | |||
| be466abc0c | |||
| 8956945e9d | |||
| a9f3e1b00d | |||
| a5e3f949c2 | |||
| acc505aa01 | |||
| 69fccb496a | |||
| 6bdbb6ea6c | |||
| 0b8a3aa646 | |||
| ed270bb926 | |||
| 56e5298cce | |||
| 2bcfd3d7ee | |||
| 75235d6013 | |||
| 19631838bb | |||
| 3cab343416 | |||
| 1a253af1c0 | |||
| b81fe6dfa0 | |||
| 2a5b12343c | |||
| 66d306708d | |||
| e7598a5467 | |||
| fb6aa954b6 | |||
| 02ad283a01 | |||
| 13ba9ef2e6 | |||
| 4767e88d2b | |||
| e38d0a8bdc | |||
| 1dca929983 | |||
| 6a2bd4d274 | |||
| c91001d7e1 | |||
| 83748a27da | |||
| 3ddeeab67f | |||
| a2c78ee1ef | |||
| 1e19061ee0 | |||
| 2e078498b1 | |||
| 7291c930c4 | |||
| b8191c134a | |||
| 09061d9e4f | |||
| 2d3cb85fb2 | |||
| 3ad07d05dd | |||
| 50c1215676 | |||
| bf5879d408 | |||
| 442e82e994 | |||
| 9e96999f02 | |||
| e983693534 | |||
| b8a14a2229 | |||
| 508f83dfad | |||
| ce8d5f92b1 | |||
| ca32ff39b8 | |||
| 9da53e51f0 | |||
| 2cbac7c4be | |||
| d2394465d7 | |||
| c2615ac24d | |||
| 62e21f1f94 | |||
| 9bd95a8b17 | |||
| bfd2eed97b | |||
| 8a0b964add | |||
| 59bab51090 | |||
| 12fa550b60 | |||
| 85cdf75a5b | |||
| 0ee2b5b034 | |||
| 0c4d140bb9 | |||
| f515d74a4d | |||
| 79835b3439 | |||
| d518b76878 | |||
| 5179db40db | |||
| 9cbeecae64 | |||
| c5af81c94f | |||
| ae966710a9 | |||
| 9b14135dd3 | |||
| 6f92cd8157 | |||
| 6545f8165f | |||
| 4a742442fb | |||
| f84144fcaa | |||
| e09f373f84 | |||
| e50be7db07 | |||
| f03ab4ef55 | |||
| 3d26c6c145 | |||
| a4b0c84f79 | |||
| e67e7c4246 | |||
| e0c4cc5d77 | |||
| b43ffad708 | |||
| cab7a59d2b | |||
| 4a2fd67e51 | |||
| da3a0049a0 | |||
| bb53ba6275 | |||
| 7a6c7c6efa | |||
| 251c9aaae6 |
@@ -15,7 +15,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
uses: astral-sh/setup-uv@4959332f0f014c5280e7eac8b70c90cb574c9f9b # v6
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -24,14 +24,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0
|
||||
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
uses: astral-sh/setup-uv@4959332f0f014c5280e7eac8b70c90cb574c9f9b # v6
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
+10
-3
@@ -1,13 +1,20 @@
|
||||
repos:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.2
|
||||
rev: v4.8.3
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- pre-push
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.2
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
name: ruff-check
|
||||
entry: uv run ruff check
|
||||
language: system
|
||||
types: [python]
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
entry: uv run ruff format
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
@@ -1,3 +1,74 @@
|
||||
## v0.7.2 (2025-08-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **client**: Use paging to fetch all notes
|
||||
|
||||
## v0.7.1 (2025-08-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||
|
||||
## v0.7.0 (2025-08-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- **contacts**: Initialize Contacts App
|
||||
|
||||
## v0.6.1 (2025-08-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- **calendar**: Fix iCalendar date vs datetime format
|
||||
- **calendar**: Remove try/except in calendar API
|
||||
|
||||
## v0.6.0 (2025-07-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to pass CI checks
|
||||
- **calendar**: address PR feedback from maintainer
|
||||
|
||||
### Refactor
|
||||
|
||||
- **calendar**: optimize logging for production readiness
|
||||
|
||||
## v0.5.0 (2025-07-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- Update webdav client create_directory method to handle recursive directories
|
||||
- **webdav**: add complete file system support
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to test_webdav_operations.py
|
||||
|
||||
## v0.4.1 (2025-07-10)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||
|
||||
## v0.4.0 (2025-07-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add TablesClient and associated tools
|
||||
|
||||
### Fix
|
||||
|
||||
- update tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Modularize NC and Notes app client
|
||||
|
||||
## v0.3.0 (2025-06-06)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run integration tests only
|
||||
uv run pytest -m integration
|
||||
|
||||
# Run tests with coverage
|
||||
uv run pytest --cov
|
||||
|
||||
# Skip integration tests
|
||||
uv run pytest -m "not integration"
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format and lint code
|
||||
uv run ruff check
|
||||
uv run ruff format
|
||||
|
||||
# Type checking
|
||||
# No explicit type checker configured - this is a Python project using ruff for linting
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
```bash
|
||||
# Local development - load environment variables and run
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
|
||||
# Docker development environment with Nextcloud instance
|
||||
docker-compose up
|
||||
|
||||
# After code changes, rebuild and restart only the MCP server container
|
||||
docker-compose up --build -d mcp
|
||||
|
||||
# Build Docker image
|
||||
docker build -t nextcloud-mcp-server .
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Install development dependencies
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
||||
|
||||
### Core Components
|
||||
|
||||
- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework
|
||||
- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs
|
||||
- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality
|
||||
- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search)
|
||||
|
||||
### Client Architecture
|
||||
|
||||
- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients
|
||||
- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic
|
||||
- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient`
|
||||
|
||||
### Server Integration
|
||||
|
||||
Each Nextcloud app has a corresponding server module that:
|
||||
1. Defines MCP tools using `@mcp.tool()` decorators
|
||||
2. Defines MCP resources using `@mcp.resource()` decorators
|
||||
3. Uses the context pattern to access the `NextcloudClient` instance
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
|
||||
- **Notes** - Full CRUD operations and search
|
||||
- **Calendar** - CalDAV integration with events, recurring events, attendees
|
||||
- **Contacts** - CardDAV integration with address book operations
|
||||
- **Tables** - Row-level operations on Nextcloud Tables
|
||||
- **WebDAV** - Complete file system access
|
||||
|
||||
### Key Patterns
|
||||
|
||||
1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables
|
||||
2. **Async/await throughout** - All operations are async using httpx
|
||||
3. **Retry logic** - `@retry_on_429` decorator handles rate limiting
|
||||
4. **Context injection** - MCP context provides access to the authenticated client instance
|
||||
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
||||
|
||||
### Testing Structure
|
||||
|
||||
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
|
||||
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- Tests are marked with `@pytest.mark.integration` for selective running
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
|
||||
|
||||
#### Testing Best Practices
|
||||
- **Always restart MCP server** after code changes with `docker-compose up --build -d mcp`
|
||||
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
|
||||
- `temporary_note` - Creates and cleans up test notes automatically
|
||||
- `temporary_addressbook` - Creates and cleans up test address books
|
||||
- `temporary_contact` - Creates and cleans up test contacts
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.7.17-python3.11-alpine@sha256:99e71b85ff6382a9853c3504f549a52ca7742a89c8bf5681487f80528f8e094a
|
||||
FROM ghcr.io/astral-sh/uv:0.8.14-python3.11-alpine@sha256:7b1463148981d57ed2d9c2950f570fe5fdd88570970f9f56f6e0e5a8829eca95
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,4 +6,4 @@ COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"]
|
||||
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/app.py:mcp"]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
# Nextcloud MCP Server
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
@@ -15,7 +13,10 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| App | Support Status | Description |
|
||||
|-----|----------------|-------------|
|
||||
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
|
||||
| **Calendar** | ✅ Full Support | Complete calendar integration - create, update, delete events. Support for recurring events, reminders, attendees, and all-day events via CalDAV. |
|
||||
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
|
||||
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
|
||||
| **Contacts** | ✅ Full Support | Create, read, update, and delete contacts and address books via CardDAV. |
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -30,6 +31,33 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| `nc_notes_delete_note` | Delete a note by ID |
|
||||
| `nc_notes_search_notes` | Search notes by title or content |
|
||||
|
||||
### Calendar Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_calendar_list_calendars` | List all available calendars for the user |
|
||||
| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) |
|
||||
| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) |
|
||||
| `nc_calendar_get_event` | Get detailed information about a specific event |
|
||||
| `nc_calendar_update_event` | Update any aspect of an existing event |
|
||||
| `nc_calendar_delete_event` | Delete a calendar event |
|
||||
| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults |
|
||||
| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days |
|
||||
| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection |
|
||||
| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria |
|
||||
| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties |
|
||||
|
||||
### Contacts Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_contacts_list_addressbooks` | List all available addressbooks for the user |
|
||||
| `nc_contacts_list_contacts` | List all contacts in a specific addressbook |
|
||||
| `nc_contacts_create_addressbook` | Create a new addressbook |
|
||||
| `nc_contacts_delete_addressbook` | Delete an addressbook |
|
||||
| `nc_contacts_create_contact` | Create a new contact in an addressbook |
|
||||
| `nc_contacts_delete_contact` | Delete a contact from an addressbook |
|
||||
|
||||
### Tables Tools
|
||||
|
||||
| Tool | Description |
|
||||
@@ -41,6 +69,16 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| `nc_tables_update_row` | Update an existing row in a table |
|
||||
| `nc_tables_delete_row` | Delete a row from a table |
|
||||
|
||||
### WebDAV File System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_webdav_list_directory` | List files and directories in any NextCloud path |
|
||||
| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) |
|
||||
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
||||
| `nc_webdav_create_directory` | Create new directories |
|
||||
| `nc_webdav_delete_resource` | Delete files or directories |
|
||||
|
||||
## Available Resources
|
||||
|
||||
| Resource | Description |
|
||||
@@ -49,6 +87,129 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| `notes://settings` | Access Notes app settings |
|
||||
| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes |
|
||||
|
||||
### WebDAV File System Access
|
||||
|
||||
The server provides complete file system access to your NextCloud instance, enabling you to:
|
||||
|
||||
- Browse any directory structure
|
||||
- Read and write files of any type
|
||||
- Create and delete directories
|
||||
- Manage your NextCloud files directly through LLM interactions
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List files in root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# Browse a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
|
||||
# Read a text file
|
||||
content = await nc_webdav_read_file("Documents/readme.txt")
|
||||
|
||||
# Create a new directory
|
||||
await nc_webdav_create_directory("NewProject/docs")
|
||||
|
||||
# Write content to a file
|
||||
await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...")
|
||||
|
||||
# Delete a file or directory
|
||||
await nc_webdav_delete_resource("old_file.txt")
|
||||
```
|
||||
|
||||
### Calendar Integration
|
||||
|
||||
The server provides comprehensive calendar integration through CalDAV, enabling you to:
|
||||
|
||||
- List all available calendars
|
||||
- Create, read, update, and delete calendar events
|
||||
- Handle recurring events with RRULE support
|
||||
- Manage event reminders and notifications
|
||||
- Support all-day and timed events
|
||||
- Handle attendees and meeting invitations
|
||||
- Organize events with categories and priorities
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List available calendars
|
||||
calendars = await nc_calendar_list_calendars()
|
||||
|
||||
# Create a simple event
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="personal",
|
||||
title="Team Meeting",
|
||||
start_datetime="2025-07-28T14:00:00",
|
||||
end_datetime="2025-07-28T15:00:00",
|
||||
description="Weekly team sync",
|
||||
location="Conference Room A"
|
||||
)
|
||||
|
||||
# Create a recurring weekly meeting
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="work",
|
||||
title="Weekly Standup",
|
||||
start_datetime="2025-07-28T09:00:00",
|
||||
end_datetime="2025-07-28T09:30:00",
|
||||
recurring=True,
|
||||
recurrence_rule="FREQ=WEEKLY;BYDAY=MO"
|
||||
)
|
||||
|
||||
# Quick meeting creation
|
||||
await nc_calendar_create_meeting(
|
||||
title="Client Call",
|
||||
date="2025-07-28",
|
||||
time="15:00",
|
||||
duration_minutes=60,
|
||||
attendees="client@example.com,colleague@company.com"
|
||||
)
|
||||
|
||||
# Get upcoming events
|
||||
events = await nc_calendar_get_upcoming_events(days_ahead=7)
|
||||
|
||||
# Advanced search - find all meetings with 5+ attendees lasting 2+ hours
|
||||
long_meetings = await nc_calendar_list_events(
|
||||
calendar_name="", # Search all calendars
|
||||
search_all_calendars=True,
|
||||
start_date="2025-07-01",
|
||||
end_date="2025-07-31",
|
||||
min_attendees=5,
|
||||
min_duration_minutes=120,
|
||||
title_contains="meeting"
|
||||
)
|
||||
|
||||
# Find availability for a 1-hour meeting with specific attendees
|
||||
availability = await nc_calendar_find_availability(
|
||||
duration_minutes=60,
|
||||
attendees="sarah@company.com,mike@company.com",
|
||||
date_range_start="2025-07-28",
|
||||
date_range_end="2025-08-04",
|
||||
business_hours_only=True,
|
||||
exclude_weekends=True,
|
||||
preferred_times="09:00-12:00,14:00-17:00"
|
||||
)
|
||||
|
||||
# Bulk update all team meetings to new location
|
||||
bulk_result = await nc_calendar_bulk_operations(
|
||||
operation="update",
|
||||
title_contains="team meeting",
|
||||
start_date="2025-08-01",
|
||||
end_date="2025-08-31",
|
||||
new_location="Conference Room B",
|
||||
new_reminder_minutes=15
|
||||
)
|
||||
|
||||
# Create a new project calendar
|
||||
new_calendar = await nc_calendar_manage_calendar(
|
||||
action="create",
|
||||
calendar_name="project-alpha",
|
||||
display_name="Project Alpha Calendar",
|
||||
description="Calendar for Project Alpha team",
|
||||
color="#FF5722"
|
||||
)
|
||||
```
|
||||
|
||||
### Note Attachments
|
||||
|
||||
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
|
||||
@@ -94,6 +255,7 @@ NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
|
||||
* `NEXTCLOUD_HOST`: The full URL of your Nextcloud instance.
|
||||
* `NEXTCLOUD_USERNAME`: Your Nextcloud username.
|
||||
* `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure.
|
||||
* `FASTMCP_HOST`: _Optional:_ By default FastMCP binds to localhost. Use this variable to set a different binding address (e.g. `0.0.0.0`)
|
||||
|
||||
## Running the Server
|
||||
|
||||
@@ -106,10 +268,12 @@ Ensure your environment variables are loaded, then run the server using `mcp run
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run the server
|
||||
mcp run --transport sse nextcloud_mcp_server.server:mcp
|
||||
mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
```
|
||||
|
||||
The server will start, typically listening on `http://0.0.0.0:8000`.
|
||||
The server will start, typically listening on `http://localhost:8000`.
|
||||
|
||||
> NOTE: To make the server bind to a different address, use the FASTMCP_HOST environmental variable
|
||||
|
||||
### Using Docker
|
||||
|
||||
@@ -141,4 +305,6 @@ Contributions are welcome! Please feel free to submit issues or pull requests on
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "Installing and configuring Calendar app..."
|
||||
|
||||
# Enable calendar app
|
||||
php /var/www/html/occ app:enable calendar
|
||||
|
||||
# Wait for calendar app to be fully initialized
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
sleep 5
|
||||
|
||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
||||
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
||||
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||
|
||||
# Ensure maintenance mode is off before calendar operations
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
|
||||
# Sync DAV system to ensure proper initialization
|
||||
echo "Syncing DAV system..."
|
||||
php /var/www/html/occ dav:sync-system-addressbook
|
||||
|
||||
# Repair calendar app to ensure proper setup
|
||||
echo "Repairing calendar app..."
|
||||
php /var/www/html/occ maintenance:repair --include-expensive
|
||||
|
||||
# Final wait to ensure CalDAV service is fully ready
|
||||
echo "Final CalDAV initialization wait..."
|
||||
sleep 5
|
||||
|
||||
echo "Calendar app installation complete!"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable contacts
|
||||
+4
-3
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: mariadb:lts@sha256:1e669024fc94f626b9dc48bf47b29b5339cec203c28e61a3dc372991a345daf5
|
||||
image: mariadb:lts@sha256:272084c2dec70619714df329c4ffcb336e3f8c723072c3f56f2e4015997bbf2c
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: redis:alpine@sha256:48501c5ad00d5563bc30c075c7bcef41d7d98de3e9a1e6c752068c66f0a8463b
|
||||
image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.6@sha256:0b133af69ef9fae8946205bfea06fb5cd5279f025c4d03d11f954db386b50bf9
|
||||
image: nextcloud:31.0.8@sha256:3eaddb0a9c56e6cf81ad258a5d05b78f747f6434b974f9a44e3f0dd91311b6ef
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_USERNAME=admin
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
- FASTMCP_HOST=0.0.0.0
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
from nextcloud_mcp_server.server import (
|
||||
configure_calendar_tools,
|
||||
configure_contacts_tools,
|
||||
configure_notes_tools,
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
)
|
||||
|
||||
setup_logging()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
client: NextcloudClient
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
logging.info("Creating Nextcloud client")
|
||||
client = NextcloudClient.from_env()
|
||||
logging.info("Client initialization wait complete.")
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
await client.close()
|
||||
|
||||
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.capabilities()
|
||||
|
||||
|
||||
configure_notes_tools(mcp)
|
||||
configure_tables_tools(mcp)
|
||||
configure_webdav_tools(mcp)
|
||||
configure_calendar_tools(mcp)
|
||||
configure_contacts_tools(mcp)
|
||||
|
||||
|
||||
def run():
|
||||
mcp.run()
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Base client for Nextcloud operations with shared authentication."""
|
||||
|
||||
from abc import ABC
|
||||
from httpx import AsyncClient
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseNextcloudClient(ABC):
|
||||
"""Base class for all Nextcloud app clients."""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, username: str):
|
||||
"""Initialize with shared HTTP client and username.
|
||||
|
||||
Args:
|
||||
http_client: Authenticated AsyncClient instance
|
||||
username: Nextcloud username for WebDAV operations
|
||||
"""
|
||||
self._client = http_client
|
||||
self.username = username
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def _make_request(self, method: str, url: str, **kwargs):
|
||||
"""Common request wrapper with logging and error handling.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
url: Request URL
|
||||
**kwargs: Additional request parameters
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
logger.debug(f"Making {method} request to {url}")
|
||||
response = await self._client.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
@@ -1,34 +1,54 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from httpx import (
|
||||
AsyncClient,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
Response,
|
||||
AsyncBaseTransport,
|
||||
AsyncHTTPTransport,
|
||||
)
|
||||
import logging
|
||||
|
||||
from .notes_client import NotesClient
|
||||
from .webdav_client import WebDAVClient
|
||||
from .tables_client import TablesClient
|
||||
from .controllers.notes_search import NotesSearchController
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .notes import NotesClient
|
||||
from .tables import TablesClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
logger.info(
|
||||
async def log_request(request: Request):
|
||||
logger.debug(
|
||||
"Request event hook: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.info("Request body: %s", request.content)
|
||||
logger.info("Headers: %s", request.headers)
|
||||
logger.debug("Request body: %s", request.content)
|
||||
logger.debug("Headers: %s", request.headers)
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
response.read() # Explicitly read the stream before accessing .text
|
||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
||||
async def log_response(response: Response):
|
||||
await response.aread()
|
||||
logger.debug("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class AsyncDisableCookieTransport(AsyncBaseTransport):
|
||||
"""This Transport disable cookies from accumulating in the httpx AsyncClient
|
||||
|
||||
Thanks to: https://github.com/encode/httpx/issues/2992#issuecomment-2133258994
|
||||
"""
|
||||
|
||||
def __init__(self, transport: AsyncBaseTransport):
|
||||
self.transport = transport
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
response = await self.transport.handle_async_request(request)
|
||||
response.headers.pop("set-cookie", None)
|
||||
return response
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
@@ -39,13 +59,16 @@ class NextcloudClient:
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
# event_hooks={"request": [log_request], "response": [log_response]},
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
# Initialize app clients
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
self.calendar = CalendarClient(self._client, username)
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
|
||||
# Initialize controllers
|
||||
self._notes_search = NotesSearchController()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Base client for Nextcloud operations with shared authentication."""
|
||||
|
||||
import logging
|
||||
from abc import ABC
|
||||
|
||||
from functools import wraps
|
||||
import time
|
||||
from httpx import HTTPStatusError, codes, RequestError, AsyncClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_on_429(func):
|
||||
"""This decorator handles the 429 response from REST APIs
|
||||
|
||||
The `func` is assumed to be a method that is similar to `httpx.Client.get`,
|
||||
and returns an `httpx.Response` object. In the case of `Too Many Requests` HTTP
|
||||
response, the function will wait for a couple of seconds and retry the request.
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 5
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retries = 0
|
||||
|
||||
while retries < MAX_RETRIES:
|
||||
try:
|
||||
# Make GET API call
|
||||
retries += 1
|
||||
response = await func(*args, **kwargs)
|
||||
break
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# If we get a '429 Client Error: Too Many Requests'
|
||||
# error we wait a couple of seconds and do a retry
|
||||
if e.response.status_code == codes.TOO_MANY_REQUESTS:
|
||||
logger.warning(
|
||||
f"429 Client Error: Too Many Requests, Number of attempts: {retries}"
|
||||
)
|
||||
time.sleep(5)
|
||||
elif e.response.status_code == 404:
|
||||
# 404 errors are often expected (e.g., checking if attachments exist)
|
||||
# Log as debug instead of warning
|
||||
logger.debug(
|
||||
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
logger.warning(
|
||||
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
except RequestError as e:
|
||||
logger.warning(
|
||||
f"RequestError {e.request.url}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
|
||||
# If for loop ends without break statement
|
||||
else:
|
||||
logger.warning("All API call retries failed")
|
||||
raise RuntimeError(
|
||||
f"Maximum number of retries ({MAX_RETRIES}) exceeded without success"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseNextcloudClient(ABC):
|
||||
"""Base class for all Nextcloud app clients."""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, username: str):
|
||||
"""Initialize with shared HTTP client and username.
|
||||
|
||||
Args:
|
||||
http_client: Authenticated AsyncClient instance
|
||||
username: Nextcloud username for WebDAV operations
|
||||
"""
|
||||
self._client = http_client
|
||||
self.username = username
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
@retry_on_429
|
||||
async def _make_request(self, method: str, url: str, **kwargs):
|
||||
"""Common request wrapper with logging and error handling.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
url: Request URL
|
||||
**kwargs: Additional request parameters
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
"""
|
||||
logger.debug(f"Making {method} request to {url}")
|
||||
response = await self._client.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
"""CardDAV client for NextCloud contacts operations."""
|
||||
|
||||
import logging
|
||||
from .base import BaseNextcloudClient
|
||||
import xml.etree.ElementTree as ET
|
||||
from pythonvCard4.vcard import Contact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactsClient(BaseNextcloudClient):
|
||||
"""Client for NextCloud CardDAV contact operations."""
|
||||
|
||||
def _get_carddav_base_path(self) -> str:
|
||||
"""Helper to get the base CardDAV path for contacts."""
|
||||
return f"/remote.php/dav/addressbooks/users/{self.username}"
|
||||
|
||||
async def list_addressbooks(self):
|
||||
"""List all available addressbooks for the user."""
|
||||
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getctag />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {
|
||||
# "Depth": "0",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
response = await self._make_request(
|
||||
"PROPFIND", carddav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
|
||||
ns = {"d": "DAV:"}
|
||||
|
||||
# logger.info(response.content)
|
||||
root = ET.fromstring(response.content)
|
||||
addressbooks = []
|
||||
for response_elem in root.findall(".//d:response", ns):
|
||||
href = response_elem.find(".//d:href", ns)
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
if not href_text.endswith("/"):
|
||||
continue # Skip non-addressbook resources
|
||||
|
||||
# Extract addressbook name from href
|
||||
addressbook_name = href_text.rstrip("/").split("/")[-1]
|
||||
if not addressbook_name or addressbook_name == self.username:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
displayname_elem = prop.find(".//d:displayname", ns)
|
||||
displayname = (
|
||||
displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else addressbook_name
|
||||
)
|
||||
|
||||
getctag_elem = prop.find(".//d:getctag", ns)
|
||||
getctag = getctag_elem.text if getctag_elem is not None else None
|
||||
|
||||
addressbooks.append(
|
||||
{
|
||||
"name": addressbook_name,
|
||||
"display_name": displayname,
|
||||
"getctag": getctag,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(addressbooks)} addressbooks")
|
||||
return addressbooks
|
||||
|
||||
async def create_addressbook(self, *, name: str, display_name: str):
|
||||
"""Create a new addressbook."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{name}/"
|
||||
|
||||
prop_body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<c:addressbook/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>{display_name}</d:displayname>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:mkcol>"""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/xml",
|
||||
}
|
||||
|
||||
await self._make_request("MKCOL", url, content=prop_body, headers=headers)
|
||||
|
||||
async def delete_addressbook(self, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{name}/"
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
|
||||
"""Create a new contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid)
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
|
||||
|
||||
vcard = contact.to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
"If-None-Match": "*",
|
||||
}
|
||||
|
||||
await self._make_request("PUT", url, content=vcard, headers=headers)
|
||||
|
||||
async def delete_contact(self, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def update_contact(
|
||||
self, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
):
|
||||
"""Update an existing contact while preserving all existing properties."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
# Get raw vCard content to preserve all properties including extended ones
|
||||
raw_vcard_content = ""
|
||||
if not etag:
|
||||
try:
|
||||
raw_vcard_content, current_etag = await self._get_raw_vcard(
|
||||
addressbook, uid
|
||||
)
|
||||
etag = current_etag
|
||||
except Exception:
|
||||
# Fall back to creating new vCard if we can't get existing
|
||||
logger.warning(
|
||||
f"Could not fetch existing vCard for {uid}, creating new"
|
||||
)
|
||||
raw_vcard_content = ""
|
||||
|
||||
# Create updated vCard preserving existing properties
|
||||
if raw_vcard_content:
|
||||
vcard_content = self._merge_vcard_properties(
|
||||
raw_vcard_content, contact_data, uid
|
||||
)
|
||||
else:
|
||||
# Fallback to creating new vCard if we couldn't get existing
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid)
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
|
||||
vcard_content = contact.to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
}
|
||||
if etag:
|
||||
headers["If-Match"] = etag
|
||||
|
||||
await self._make_request("PUT", url, content=vcard_content, headers=headers)
|
||||
|
||||
async def list_contacts(self, *, addressbook: str):
|
||||
"""List all available contacts for addressbook."""
|
||||
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
|
||||
report_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
<card:address-data />
|
||||
</d:prop>
|
||||
</card:addressbook-query>"""
|
||||
|
||||
headers = {
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
response = await self._make_request(
|
||||
"REPORT",
|
||||
f"{carddav_path}/{addressbook}",
|
||||
content=report_body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
|
||||
|
||||
# logger.info(response.text)
|
||||
root = ET.fromstring(response.content)
|
||||
contacts = []
|
||||
for response_elem in root.findall(".//d:response", ns):
|
||||
href = response_elem.find(".//d:href", ns)
|
||||
if href is None:
|
||||
logger.info("Skip missing href")
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
# logger.info("Href text: %s", href_text)
|
||||
# if not href_text.endswith("/"):
|
||||
# logger.info("# Skip non-addressbook resources")
|
||||
# continue
|
||||
|
||||
# Extract vcard id from href
|
||||
vcard_id = href_text.rstrip("/").split("/")[-1]
|
||||
if not vcard_id:
|
||||
logger.info("Skip missing vcard_id")
|
||||
continue
|
||||
vcard_id = vcard_id.replace(".vcf", "")
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//d:propstat", ns)
|
||||
if propstat is None:
|
||||
logger.info("Skip missing propstat")
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//d:prop", ns)
|
||||
if prop is None:
|
||||
logger.info("Skip missing prop")
|
||||
continue
|
||||
|
||||
getetag_elem = prop.find(".//d:getetag", ns)
|
||||
getetag = getetag_elem.text if getetag_elem is not None else None
|
||||
|
||||
addressdata_elem = prop.find(".//card:address-data", ns)
|
||||
addressdata = (
|
||||
addressdata_elem.text if addressdata_elem is not None else None
|
||||
)
|
||||
if addressdata is None:
|
||||
logger.info("Skip missing addressdata")
|
||||
continue
|
||||
|
||||
contact = Contact.from_vcard(addressdata)
|
||||
|
||||
contacts.append(
|
||||
{
|
||||
"vcard_id": vcard_id,
|
||||
"getetag": getetag,
|
||||
"contact": {
|
||||
"fullname": contact.fn,
|
||||
"nickname": contact.nickname,
|
||||
"birthday": contact.bday,
|
||||
"email": contact.email,
|
||||
},
|
||||
"addressdata": addressdata,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(contacts)} contacts")
|
||||
return contacts
|
||||
|
||||
async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]:
|
||||
"""Get raw vCard content for a contact without parsing."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", url)
|
||||
etag = response.headers.get("etag", "")
|
||||
return response.text, etag
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting raw vCard for {uid}: {e}")
|
||||
raise
|
||||
|
||||
def _merge_vcard_properties(
|
||||
self, raw_vcard: str, contact_data: dict, uid: str
|
||||
) -> str:
|
||||
"""Merge new contact data into existing raw vCard while preserving all properties."""
|
||||
try:
|
||||
# Instead of using pythonvCard4 which has formatting issues,
|
||||
# let's do a simple text-based merge to preserve exact formatting
|
||||
|
||||
# Start with the original vCard
|
||||
lines = raw_vcard.strip().split("\n")
|
||||
updated_lines = []
|
||||
|
||||
# Track what we've updated to avoid duplicates
|
||||
updated_properties = set()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip the END:VCARD line for now
|
||||
if line == "END:VCARD":
|
||||
continue
|
||||
|
||||
property_name = line.split(":")[0].split(";")[0]
|
||||
|
||||
# Handle updates for specific properties
|
||||
if property_name == "FN" and "fn" in contact_data:
|
||||
updated_lines.append(f"FN:{contact_data['fn']}")
|
||||
updated_properties.add("fn")
|
||||
elif property_name == "EMAIL" and "email" in contact_data:
|
||||
# Replace first email with new one, preserve others
|
||||
if "email" not in updated_properties:
|
||||
if isinstance(contact_data["email"], str):
|
||||
# Try to preserve the original format as much as possible
|
||||
if ";TYPE=" in line:
|
||||
type_part = line.split(";TYPE=")[1].split(":")[0]
|
||||
updated_lines.append(
|
||||
f"EMAIL;TYPE={type_part}:{contact_data['email']}"
|
||||
)
|
||||
else:
|
||||
updated_lines.append(f"EMAIL:{contact_data['email']}")
|
||||
updated_properties.add("email")
|
||||
else:
|
||||
# Keep additional emails unchanged
|
||||
updated_lines.append(line)
|
||||
elif property_name == "TEL" and "tel" in contact_data:
|
||||
# Similar handling for phone numbers
|
||||
if "tel" not in updated_properties:
|
||||
if isinstance(contact_data["tel"], str):
|
||||
if ";TYPE=" in line:
|
||||
type_part = line.split(";TYPE=")[1].split(":")[0]
|
||||
updated_lines.append(
|
||||
f"TEL;TYPE={type_part}:{contact_data['tel']}"
|
||||
)
|
||||
else:
|
||||
updated_lines.append(f"TEL:{contact_data['tel']}")
|
||||
updated_properties.add("tel")
|
||||
else:
|
||||
# Keep additional phone numbers unchanged
|
||||
updated_lines.append(line)
|
||||
elif property_name == "NOTE" and "note" in contact_data:
|
||||
updated_lines.append(f"NOTE:{contact_data['note']}")
|
||||
updated_properties.add("note")
|
||||
elif property_name == "NICKNAME" and "nickname" in contact_data:
|
||||
nickname_value = contact_data["nickname"]
|
||||
if isinstance(nickname_value, list):
|
||||
nickname_value = ",".join(nickname_value)
|
||||
updated_lines.append(f"NICKNAME:{nickname_value}")
|
||||
updated_properties.add("nickname")
|
||||
elif property_name == "BDAY" and "bday" in contact_data:
|
||||
updated_lines.append(f"BDAY:{contact_data['bday']}")
|
||||
updated_properties.add("bday")
|
||||
elif property_name == "CATEGORIES" and "categories" in contact_data:
|
||||
categories_value = contact_data["categories"]
|
||||
if isinstance(categories_value, list):
|
||||
categories_value = ",".join(categories_value)
|
||||
updated_lines.append(f"CATEGORIES:{categories_value}")
|
||||
updated_properties.add("categories")
|
||||
elif property_name == "ORG" and (
|
||||
"org" in contact_data or "organization" in contact_data
|
||||
):
|
||||
org_value = contact_data.get("org") or contact_data.get(
|
||||
"organization"
|
||||
)
|
||||
updated_lines.append(f"ORG:{org_value}")
|
||||
updated_properties.add("org")
|
||||
elif property_name == "TITLE" and "title" in contact_data:
|
||||
updated_lines.append(f"TITLE:{contact_data['title']}")
|
||||
updated_properties.add("title")
|
||||
else:
|
||||
# Keep all other properties unchanged (preserves all extended/custom fields)
|
||||
updated_lines.append(line)
|
||||
|
||||
# Add any new properties that weren't in the original vCard
|
||||
for key, value in contact_data.items():
|
||||
if key not in updated_properties:
|
||||
if key == "fn":
|
||||
updated_lines.append(f"FN:{value}")
|
||||
elif key == "email" and isinstance(value, str):
|
||||
updated_lines.append(f"EMAIL:{value}")
|
||||
elif key == "tel" and isinstance(value, str):
|
||||
updated_lines.append(f"TEL:{value}")
|
||||
elif key == "note":
|
||||
updated_lines.append(f"NOTE:{value}")
|
||||
elif key == "nickname":
|
||||
nickname_value = (
|
||||
value if isinstance(value, str) else ",".join(value)
|
||||
)
|
||||
updated_lines.append(f"NICKNAME:{nickname_value}")
|
||||
elif key == "bday":
|
||||
updated_lines.append(f"BDAY:{value}")
|
||||
elif key == "categories":
|
||||
categories_value = (
|
||||
value if isinstance(value, str) else ",".join(value)
|
||||
)
|
||||
updated_lines.append(f"CATEGORIES:{categories_value}")
|
||||
elif key in ["org", "organization"]:
|
||||
updated_lines.append(f"ORG:{value}")
|
||||
elif key == "title":
|
||||
updated_lines.append(f"TITLE:{value}")
|
||||
|
||||
# Add the END:VCARD line
|
||||
updated_lines.append("END:VCARD")
|
||||
|
||||
# Join all lines
|
||||
return "\n".join(updated_lines)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error merging vCard properties: {e}")
|
||||
# Fallback to creating basic vCard matching Nextcloud format
|
||||
basic_vcard = f"""BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:{uid}
|
||||
FN:{contact_data.get("fn", "Unknown")}"""
|
||||
|
||||
if "email" in contact_data:
|
||||
basic_vcard += f"\nEMAIL:{contact_data['email']}"
|
||||
if "tel" in contact_data:
|
||||
basic_vcard += f"\nTEL:{contact_data['tel']}"
|
||||
|
||||
basic_vcard += "\nEND:VCARD"
|
||||
return basic_vcard
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base_client import BaseNextcloudClient
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,8 +18,21 @@ class NotesClient(BaseNextcloudClient):
|
||||
|
||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all notes."""
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/notes")
|
||||
return response.json()
|
||||
notes = []
|
||||
cursor = ""
|
||||
|
||||
while True:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/apps/notes/api/v1/notes",
|
||||
params={"chunkSize": 50, "chunkCursor": cursor},
|
||||
)
|
||||
notes.extend(response.json())
|
||||
if "X-Notes-Chunk-Cursor" not in response.headers:
|
||||
break
|
||||
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
||||
|
||||
return notes
|
||||
|
||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Get a specific note by ID."""
|
||||
@@ -106,7 +119,7 @@ class NotesClient(BaseNextcloudClient):
|
||||
)
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .webdav_client import WebDAVClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
await webdav_client.cleanup_old_attachment_directory(
|
||||
@@ -152,7 +165,7 @@ class NotesClient(BaseNextcloudClient):
|
||||
|
||||
# Clean up attachment directories
|
||||
try:
|
||||
from .webdav_client import WebDAVClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base_client import BaseNextcloudClient
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud WebDAV operations."""
|
||||
|
||||
async def delete_resource(self, path: str) -> Dict[str, Any]:
|
||||
"""Delete a resource (file or directory) via WebDAV DELETE."""
|
||||
# Ensure path ends with a slash if it's a directory
|
||||
if not path.endswith("/"):
|
||||
path_with_slash = f"{path}/"
|
||||
else:
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.debug(f"Deleting WebDAV resource: {webdav_path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First try a PROPFIND to verify resource exists
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await self._make_request(
|
||||
"PROPFIND", webdav_path, headers=propfind_headers
|
||||
)
|
||||
logger.debug(
|
||||
f"Resource exists check status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' doesn't exist, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = await self._make_request("DELETE", webdav_path, headers=headers)
|
||||
logger.debug(f"Successfully deleted WebDAV resource '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' not found, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
else:
|
||||
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_old_attachment_directory(
|
||||
self, note_id: int, old_category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up the attachment directory for a note in its old category location."""
|
||||
old_category_path_part = f"{old_category}/" if old_category else ""
|
||||
old_attachment_dir_path = (
|
||||
f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
)
|
||||
|
||||
logger.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.debug(f"Cleanup result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_note_attachments(
|
||||
self, note_id: int, category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up attachment directory for a specific note and category."""
|
||||
cat_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.debug(
|
||||
f"Cleaning up attachments for note {note_id} in category '{category}'"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.debug(f"Cleanup result for note {note_id}: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed cleaning up attachments for note {note_id}: {e}")
|
||||
raise e
|
||||
|
||||
async def add_note_attachment(
|
||||
self,
|
||||
note_id: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
category: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add/Update an attachment to a note via WebDAV PUT."""
|
||||
# Construct paths based on provided category
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
parent_dir_webdav_rel_path = (
|
||||
f"Notes/{category_path_part}{attachment_dir_segment}"
|
||||
)
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
|
||||
attachment_path = f"{parent_dir_path}/{filename}"
|
||||
|
||||
logger.debug(f"Uploading attachment '{filename}' for note {note_id}")
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First check if we can access WebDAV at all
|
||||
notes_dir_path = f"{webdav_base}/Notes"
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
notes_dir_response = await self._make_request(
|
||||
"PROPFIND", notes_dir_path, headers=propfind_headers
|
||||
)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error("WebDAV authentication failed for Notes directory")
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
response=notes_dir_response,
|
||||
)
|
||||
elif notes_dir_response.status_code >= 400:
|
||||
logger.error(
|
||||
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
|
||||
)
|
||||
notes_dir_response.raise_for_status()
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
mkcol_response = await self._make_request(
|
||||
"MKCOL", parent_dir_path, headers=mkcol_headers
|
||||
)
|
||||
|
||||
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.error(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
|
||||
# Proceed with the PUT request
|
||||
response = await self._make_request(
|
||||
"PUT", attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id}"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def get_note_attachment(
|
||||
self, note_id: int, filename: str, category: Optional[str] = None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Fetch a specific attachment from a note via WebDAV GET."""
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.debug(f"Fetching attachment '{filename}' for note {note_id}")
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", attachment_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.debug(
|
||||
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Attachment '{filename}' not found for note {note_id}")
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
|
||||
"""List files and directories in the specified path via WebDAV PROPFIND."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Listing directory: {path}")
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"PROPFIND", webdav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
items = []
|
||||
|
||||
# Skip the first response (the directory itself)
|
||||
responses = root.findall(".//{DAV:}response")[1:]
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory name from href
|
||||
href_text = href.text or ""
|
||||
name = href_text.rstrip("/").split("/")[-1]
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Determine if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Get other properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = (
|
||||
int(size_elem.text)
|
||||
if size_elem is not None and size_elem.text
|
||||
else 0
|
||||
)
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = (
|
||||
content_type_elem.text if content_type_elem is not None else None
|
||||
)
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": f"{path.rstrip('/')}/{name}" if path else name,
|
||||
"is_directory": is_directory,
|
||||
"size": size if not is_directory else None,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(items)} items in directory: {path}")
|
||||
return items
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
|
||||
async def read_file(self, path: str) -> Tuple[bytes, str]:
|
||||
"""Read a file's content via WebDAV GET."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Reading file: {path}")
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", webdav_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
content_type = response.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)")
|
||||
return content, content_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error reading file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def write_file(
|
||||
self, path: str, content: bytes, content_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Write content to a file via WebDAV PUT."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Writing file: {path}")
|
||||
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(path)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"PUT", webdav_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully wrote file '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error writing file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error writing file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def create_directory(
|
||||
self, path: str, recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a directory via WebDAV MKCOL."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Creating directory: {path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request("MKCOL", webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully created directory '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# Method Not Allowed - directory already exists
|
||||
if e.response.status_code == 405:
|
||||
logger.debug(f"Directory '{path}' already exists")
|
||||
return {"status_code": 405, "message": "Directory already exists"}
|
||||
|
||||
# File Conflict - parent directory does not exist
|
||||
if e.response.status_code == 409 and recursive:
|
||||
# Extract parent directory path
|
||||
path_parts = path.strip("/").split("/")
|
||||
if len(path_parts) > 1:
|
||||
parent_dir = "/".join(path_parts[:-1])
|
||||
logger.debug(
|
||||
f"Parent directory '{parent_dir}' doesn't exist, creating recursively"
|
||||
)
|
||||
await self.create_directory(parent_dir, recursive)
|
||||
# Now try to create the original directory again
|
||||
return await self.create_directory(path, recursive)
|
||||
else:
|
||||
# This shouldn't happen for single-level directories under root
|
||||
logger.error(f"409 conflict for single-level directory '{path}'")
|
||||
raise e
|
||||
|
||||
logger.error(f"HTTP error creating directory '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating directory '{path}': {e}")
|
||||
raise e
|
||||
@@ -21,12 +21,12 @@ LOGGING_CONFIG = {
|
||||
},
|
||||
"httpx": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
"httpcore": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Controller for notes search functionality."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class NotesSearchController:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Pydantic models for structured MCP server responses."""
|
||||
|
||||
# Base models
|
||||
from .base import (
|
||||
BaseResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
IdResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
|
||||
# Notes models
|
||||
from .notes import (
|
||||
Note,
|
||||
NoteSearchResult,
|
||||
NotesSettings,
|
||||
CreateNoteResponse,
|
||||
UpdateNoteResponse,
|
||||
DeleteNoteResponse,
|
||||
AppendContentResponse,
|
||||
SearchNotesResponse,
|
||||
)
|
||||
|
||||
# Calendar models
|
||||
from .calendar import (
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarEventSummary,
|
||||
CreateEventResponse,
|
||||
UpdateEventResponse,
|
||||
DeleteEventResponse,
|
||||
ListEventsResponse,
|
||||
ListCalendarsResponse,
|
||||
AvailabilitySlot,
|
||||
FindAvailabilityResponse,
|
||||
BulkOperationResult,
|
||||
BulkOperationResponse,
|
||||
CreateMeetingResponse,
|
||||
UpcomingEventsResponse,
|
||||
ManageCalendarResponse,
|
||||
)
|
||||
|
||||
# Contacts models
|
||||
from .contacts import (
|
||||
AddressBook,
|
||||
Contact,
|
||||
ContactField,
|
||||
ListAddressBooksResponse,
|
||||
ListContactsResponse,
|
||||
CreateContactResponse,
|
||||
UpdateContactResponse,
|
||||
DeleteContactResponse,
|
||||
CreateAddressBookResponse,
|
||||
DeleteAddressBookResponse,
|
||||
)
|
||||
|
||||
# Tables models
|
||||
from .tables import (
|
||||
Table,
|
||||
TableColumn,
|
||||
TableRow,
|
||||
TableView,
|
||||
TableSchema,
|
||||
ListTablesResponse,
|
||||
GetSchemaResponse,
|
||||
ReadTableResponse,
|
||||
CreateRowResponse,
|
||||
UpdateRowResponse,
|
||||
DeleteRowResponse,
|
||||
)
|
||||
|
||||
# WebDAV models
|
||||
from .webdav import (
|
||||
FileInfo,
|
||||
DirectoryListing,
|
||||
ReadFileResponse,
|
||||
WriteFileResponse,
|
||||
CreateDirectoryResponse,
|
||||
DeleteResourceResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base models
|
||||
"BaseResponse",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
"IdResponse",
|
||||
"StatusResponse",
|
||||
# Notes models
|
||||
"Note",
|
||||
"NoteSearchResult",
|
||||
"NotesSettings",
|
||||
"CreateNoteResponse",
|
||||
"UpdateNoteResponse",
|
||||
"DeleteNoteResponse",
|
||||
"AppendContentResponse",
|
||||
"SearchNotesResponse",
|
||||
# Calendar models
|
||||
"Calendar",
|
||||
"CalendarEvent",
|
||||
"CalendarEventSummary",
|
||||
"CreateEventResponse",
|
||||
"UpdateEventResponse",
|
||||
"DeleteEventResponse",
|
||||
"ListEventsResponse",
|
||||
"ListCalendarsResponse",
|
||||
"AvailabilitySlot",
|
||||
"FindAvailabilityResponse",
|
||||
"BulkOperationResult",
|
||||
"BulkOperationResponse",
|
||||
"CreateMeetingResponse",
|
||||
"UpcomingEventsResponse",
|
||||
"ManageCalendarResponse",
|
||||
# Contacts models
|
||||
"AddressBook",
|
||||
"Contact",
|
||||
"ContactField",
|
||||
"ListAddressBooksResponse",
|
||||
"ListContactsResponse",
|
||||
"CreateContactResponse",
|
||||
"UpdateContactResponse",
|
||||
"DeleteContactResponse",
|
||||
"CreateAddressBookResponse",
|
||||
"DeleteAddressBookResponse",
|
||||
# Tables models
|
||||
"Table",
|
||||
"TableColumn",
|
||||
"TableRow",
|
||||
"TableView",
|
||||
"TableSchema",
|
||||
"ListTablesResponse",
|
||||
"GetSchemaResponse",
|
||||
"ReadTableResponse",
|
||||
"CreateRowResponse",
|
||||
"UpdateRowResponse",
|
||||
"DeleteRowResponse",
|
||||
# WebDAV models
|
||||
"FileInfo",
|
||||
"DirectoryListing",
|
||||
"ReadFileResponse",
|
||||
"WriteFileResponse",
|
||||
"CreateDirectoryResponse",
|
||||
"DeleteResourceResponse",
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Base Pydantic models for common response patterns."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
"""Base response model for all MCP tool responses."""
|
||||
|
||||
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
|
||||
|
||||
success: bool = Field(
|
||||
default=True, description="Whether the operation was successful"
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=datetime.now, description="Response timestamp"
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponse(BaseResponse):
|
||||
"""Response model for error cases."""
|
||||
|
||||
success: bool = Field(default=False, description="Always False for error responses")
|
||||
error: str = Field(description="Error message")
|
||||
error_code: Optional[str] = Field(None, description="Optional error code")
|
||||
details: Optional[Dict[str, Any]] = Field(
|
||||
None, description="Additional error details"
|
||||
)
|
||||
|
||||
|
||||
class SuccessResponse(BaseResponse):
|
||||
"""Generic success response."""
|
||||
|
||||
message: Optional[str] = Field(None, description="Optional success message")
|
||||
data: Optional[Dict[str, Any]] = Field(None, description="Optional response data")
|
||||
|
||||
|
||||
class IdResponse(BaseResponse):
|
||||
"""Response model for operations that return a new ID."""
|
||||
|
||||
id: Union[int, str] = Field(description="ID of the created or affected resource")
|
||||
|
||||
|
||||
class StatusResponse(BaseResponse):
|
||||
"""Response model for operations that return just a status."""
|
||||
|
||||
status_code: Optional[int] = Field(None, description="HTTP status code")
|
||||
message: Optional[str] = Field(None, description="Status message")
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Pydantic models for Calendar app responses."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class Calendar(BaseModel):
|
||||
"""Model for a Nextcloud calendar."""
|
||||
|
||||
name: str = Field(description="Calendar name/ID")
|
||||
display_name: str = Field(description="Calendar display name")
|
||||
description: Optional[str] = Field(None, description="Calendar description")
|
||||
color: Optional[str] = Field(None, description="Calendar color")
|
||||
href: Optional[str] = Field(None, description="Calendar DAV href")
|
||||
timezone: Optional[str] = Field(None, description="Calendar timezone")
|
||||
enabled: bool = Field(default=True, description="Whether calendar is enabled")
|
||||
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
|
||||
|
||||
|
||||
class CalendarEventSummary(BaseModel):
|
||||
"""Model for calendar event summary (for lists)."""
|
||||
|
||||
uid: str = Field(description="Event UID")
|
||||
summary: str = Field(description="Event summary/title")
|
||||
start: str = Field(description="Event start datetime (ISO format)")
|
||||
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
|
||||
all_day: bool = Field(default=False, description="Whether event is all-day")
|
||||
location: Optional[str] = Field(None, description="Event location")
|
||||
description: Optional[str] = Field(None, description="Event description")
|
||||
categories: List[str] = Field(default_factory=list, description="Event categories")
|
||||
status: Optional[str] = Field(
|
||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||
)
|
||||
|
||||
|
||||
class CalendarEvent(CalendarEventSummary):
|
||||
"""Model for a complete calendar event."""
|
||||
|
||||
created: Optional[str] = Field(None, description="Event creation datetime")
|
||||
last_modified: Optional[str] = Field(None, description="Last modification datetime")
|
||||
recurring: bool = Field(default=False, description="Whether event is recurring")
|
||||
recurrence_rule: Optional[str] = Field(None, description="RFC5545 recurrence rule")
|
||||
recurrence_end: Optional[str] = Field(None, description="Recurrence end date")
|
||||
attendees: List[str] = Field(
|
||||
default_factory=list, description="List of attendee email addresses"
|
||||
)
|
||||
organizer: Optional[str] = Field(None, description="Event organizer")
|
||||
priority: Optional[int] = Field(None, description="Event priority (1-9)")
|
||||
privacy: Optional[str] = Field(None, description="Event privacy level")
|
||||
url: Optional[str] = Field(None, description="Event URL")
|
||||
duration_minutes: Optional[int] = Field(
|
||||
None, description="Event duration in minutes"
|
||||
)
|
||||
reminder_minutes: Optional[int] = Field(
|
||||
None, description="Reminder time in minutes before event"
|
||||
)
|
||||
reminder_email: bool = Field(
|
||||
default=False, description="Whether to send email reminder"
|
||||
)
|
||||
color: Optional[str] = Field(None, description="Event color")
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
|
||||
|
||||
class CreateEventResponse(BaseResponse):
|
||||
"""Response model for event creation."""
|
||||
|
||||
event: CalendarEvent = Field(description="The created event")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the event was created in"
|
||||
)
|
||||
|
||||
|
||||
class UpdateEventResponse(BaseResponse):
|
||||
"""Response model for event updates."""
|
||||
|
||||
event: CalendarEvent = Field(description="The updated event")
|
||||
calendar_name: str = Field(description="Name of the calendar the event belongs to")
|
||||
|
||||
|
||||
class DeleteEventResponse(StatusResponse):
|
||||
"""Response model for event deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted event")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the event was deleted from"
|
||||
)
|
||||
|
||||
|
||||
class ListEventsResponse(BaseResponse):
|
||||
"""Response model for listing events."""
|
||||
|
||||
events: List[CalendarEventSummary] = Field(description="List of events")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
start_date: Optional[str] = Field(None, description="Start date filter applied")
|
||||
end_date: Optional[str] = Field(None, description="End date filter applied")
|
||||
total_found: int = Field(description="Total number of events found")
|
||||
|
||||
|
||||
class ListCalendarsResponse(BaseResponse):
|
||||
"""Response model for listing calendars."""
|
||||
|
||||
calendars: List[Calendar] = Field(description="List of available calendars")
|
||||
total_count: int = Field(description="Total number of calendars")
|
||||
|
||||
|
||||
class AvailabilitySlot(BaseModel):
|
||||
"""Model for an available time slot."""
|
||||
|
||||
start: str = Field(description="Slot start datetime (ISO format)")
|
||||
end: str = Field(description="Slot end datetime (ISO format)")
|
||||
duration_minutes: int = Field(description="Slot duration in minutes")
|
||||
date: str = Field(description="Date of the slot (YYYY-MM-DD)")
|
||||
|
||||
|
||||
class FindAvailabilityResponse(BaseResponse):
|
||||
"""Response model for finding availability."""
|
||||
|
||||
available_slots: List[AvailabilitySlot] = Field(
|
||||
description="List of available time slots"
|
||||
)
|
||||
duration_requested: int = Field(description="Requested duration in minutes")
|
||||
date_range_start: str = Field(description="Start date of search range")
|
||||
date_range_end: str = Field(description="End date of search range")
|
||||
attendees_checked: List[str] = Field(
|
||||
default_factory=list, description="Attendees checked for availability"
|
||||
)
|
||||
business_hours_only: bool = Field(
|
||||
description="Whether search was limited to business hours"
|
||||
)
|
||||
|
||||
|
||||
class BulkOperationResult(BaseModel):
|
||||
"""Model for bulk operation results."""
|
||||
|
||||
operation: str = Field(description="Operation performed (update, delete, move)")
|
||||
events_processed: int = Field(description="Number of events processed")
|
||||
events_successful: int = Field(
|
||||
description="Number of events successfully processed"
|
||||
)
|
||||
events_failed: int = Field(description="Number of events that failed processing")
|
||||
failed_events: List[str] = Field(
|
||||
default_factory=list, description="UIDs of events that failed"
|
||||
)
|
||||
errors: List[str] = Field(default_factory=list, description="Error messages")
|
||||
|
||||
|
||||
class BulkOperationResponse(BaseResponse):
|
||||
"""Response model for bulk operations."""
|
||||
|
||||
result: BulkOperationResult = Field(description="Bulk operation result")
|
||||
|
||||
|
||||
class CreateMeetingResponse(CreateEventResponse):
|
||||
"""Response model for meeting creation (same as event creation)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UpcomingEventsResponse(BaseResponse):
|
||||
"""Response model for upcoming events."""
|
||||
|
||||
events: List[CalendarEventSummary] = Field(description="List of upcoming events")
|
||||
days_ahead: int = Field(description="Number of days ahead searched")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
|
||||
|
||||
class ManageCalendarResponse(BaseResponse):
|
||||
"""Response model for calendar management operations."""
|
||||
|
||||
action: str = Field(description="Action performed (create, delete, update, list)")
|
||||
calendar: Optional[Calendar] = Field(None, description="Calendar that was affected")
|
||||
calendars: Optional[List[Calendar]] = Field(
|
||||
None, description="List of calendars (for list action)"
|
||||
)
|
||||
message: str = Field(description="Success message")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Pydantic models for Contacts app responses."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class AddressBook(BaseModel):
|
||||
"""Model for a Nextcloud address book."""
|
||||
|
||||
uri: str = Field(description="Address book URI")
|
||||
displayname: str = Field(description="Address book display name")
|
||||
description: Optional[str] = Field(None, description="Address book description")
|
||||
ctag: Optional[str] = Field(
|
||||
None, description="Address book tag for synchronization"
|
||||
)
|
||||
|
||||
|
||||
class ContactField(BaseModel):
|
||||
"""Model for a contact field (email, phone, etc.)."""
|
||||
|
||||
type: str = Field(description="Field type (e.g., 'email', 'phone', 'address')")
|
||||
value: str = Field(description="Field value")
|
||||
label: Optional[str] = Field(None, description="Field label (e.g., 'work', 'home')")
|
||||
preferred: bool = Field(
|
||||
default=False, description="Whether this is the preferred field of this type"
|
||||
)
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
"""Model for a Nextcloud contact."""
|
||||
|
||||
uid: str = Field(description="Contact UID")
|
||||
fn: str = Field(description="Full name (formatted name)")
|
||||
given_name: Optional[str] = Field(None, description="Given name")
|
||||
family_name: Optional[str] = Field(None, description="Family name")
|
||||
organization: Optional[str] = Field(None, description="Organization")
|
||||
title: Optional[str] = Field(None, description="Job title")
|
||||
emails: List[ContactField] = Field(
|
||||
default_factory=list, description="Email addresses"
|
||||
)
|
||||
phones: List[ContactField] = Field(
|
||||
default_factory=list, description="Phone numbers"
|
||||
)
|
||||
addresses: List[ContactField] = Field(default_factory=list, description="Addresses")
|
||||
urls: List[ContactField] = Field(default_factory=list, description="URLs")
|
||||
note: Optional[str] = Field(None, description="Notes")
|
||||
photo: Optional[str] = Field(None, description="Photo URL or base64 data")
|
||||
birthday: Optional[str] = Field(None, description="Birthday (ISO date format)")
|
||||
categories: List[str] = Field(
|
||||
default_factory=list, description="Contact categories"
|
||||
)
|
||||
custom_fields: Dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom fields"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
|
||||
@property
|
||||
def primary_email(self) -> Optional[str]:
|
||||
"""Get the primary email address."""
|
||||
if not self.emails:
|
||||
return None
|
||||
# Return preferred email if available, otherwise first email
|
||||
preferred = next(
|
||||
(email.value for email in self.emails if email.preferred), None
|
||||
)
|
||||
return preferred or self.emails[0].value
|
||||
|
||||
@property
|
||||
def primary_phone(self) -> Optional[str]:
|
||||
"""Get the primary phone number."""
|
||||
if not self.phones:
|
||||
return None
|
||||
# Return preferred phone if available, otherwise first phone
|
||||
preferred = next(
|
||||
(phone.value for phone in self.phones if phone.preferred), None
|
||||
)
|
||||
return preferred or self.phones[0].value
|
||||
|
||||
|
||||
class ListAddressBooksResponse(BaseResponse):
|
||||
"""Response model for listing address books."""
|
||||
|
||||
addressbooks: List[AddressBook] = Field(
|
||||
description="List of available address books"
|
||||
)
|
||||
total_count: int = Field(description="Total number of address books")
|
||||
|
||||
|
||||
class ListContactsResponse(BaseResponse):
|
||||
"""Response model for listing contacts."""
|
||||
|
||||
contacts: List[Contact] = Field(description="List of contacts")
|
||||
addressbook: str = Field(description="Address book name")
|
||||
total_count: int = Field(description="Total number of contacts")
|
||||
|
||||
|
||||
class CreateContactResponse(BaseResponse):
|
||||
"""Response model for contact creation."""
|
||||
|
||||
contact: Contact = Field(description="The created contact")
|
||||
addressbook: str = Field(description="Address book the contact was created in")
|
||||
|
||||
|
||||
class UpdateContactResponse(BaseResponse):
|
||||
"""Response model for contact updates."""
|
||||
|
||||
contact: Contact = Field(description="The updated contact")
|
||||
addressbook: str = Field(description="Address book the contact belongs to")
|
||||
|
||||
|
||||
class DeleteContactResponse(StatusResponse):
|
||||
"""Response model for contact deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted contact")
|
||||
addressbook: str = Field(description="Address book the contact was deleted from")
|
||||
|
||||
|
||||
class CreateAddressBookResponse(BaseResponse):
|
||||
"""Response model for address book creation."""
|
||||
|
||||
addressbook: AddressBook = Field(description="The created address book")
|
||||
|
||||
|
||||
class DeleteAddressBookResponse(StatusResponse):
|
||||
"""Response model for address book deletion."""
|
||||
|
||||
deleted_name: str = Field(description="Name of the deleted address book")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Pydantic models for Notes app responses."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class Note(BaseModel):
|
||||
"""Model for a Nextcloud note."""
|
||||
|
||||
id: int = Field(description="Note ID")
|
||||
title: str = Field(description="Note title")
|
||||
content: str = Field(description="Note content in markdown")
|
||||
category: str = Field(default="", description="Note category")
|
||||
modified: int = Field(description="Unix timestamp of last modification")
|
||||
favorite: bool = Field(
|
||||
default=False, description="Whether note is marked as favorite"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
readonly: bool = Field(default=False, description="Whether note is read-only")
|
||||
|
||||
@property
|
||||
def modified_datetime(self) -> datetime:
|
||||
"""Convert Unix timestamp to datetime."""
|
||||
return datetime.fromtimestamp(self.modified)
|
||||
|
||||
|
||||
class NoteSearchResult(BaseModel):
|
||||
"""Model for note search results (limited fields)."""
|
||||
|
||||
id: int = Field(description="Note ID")
|
||||
title: str = Field(description="Note title")
|
||||
category: str = Field(default="", description="Note category")
|
||||
score: Optional[float] = Field(None, description="Search relevance score")
|
||||
|
||||
|
||||
class NotesSettings(BaseModel):
|
||||
"""Model for Notes app settings."""
|
||||
|
||||
notesPath: str = Field(description="Path to notes directory")
|
||||
fileSuffix: str = Field(description="File suffix for notes")
|
||||
noteMode: str = Field(description="Note mode setting")
|
||||
|
||||
|
||||
class CreateNoteResponse(IdResponse):
|
||||
"""Response model for note creation."""
|
||||
|
||||
note: Note = Field(description="The created note")
|
||||
|
||||
|
||||
class UpdateNoteResponse(BaseResponse):
|
||||
"""Response model for note updates."""
|
||||
|
||||
note: Note = Field(description="The updated note")
|
||||
|
||||
|
||||
class DeleteNoteResponse(StatusResponse):
|
||||
"""Response model for note deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of the deleted note")
|
||||
|
||||
|
||||
class AppendContentResponse(BaseResponse):
|
||||
"""Response model for appending content to a note."""
|
||||
|
||||
note: Note = Field(description="The updated note after appending content")
|
||||
|
||||
|
||||
class SearchNotesResponse(BaseResponse):
|
||||
"""Response model for note search."""
|
||||
|
||||
results: List[NoteSearchResult] = Field(description="Search results")
|
||||
query: str = Field(description="The search query used")
|
||||
total_found: int = Field(description="Total number of notes found")
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Pydantic models for Tables app responses."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class TableColumn(BaseModel):
|
||||
"""Model for a table column definition."""
|
||||
|
||||
id: int = Field(description="Column ID")
|
||||
title: str = Field(description="Column title")
|
||||
type: str = Field(description="Column type (text, number, datetime, etc.)")
|
||||
subtype: Optional[str] = Field(None, description="Column subtype")
|
||||
mandatory: bool = Field(default=False, description="Whether column is mandatory")
|
||||
description: Optional[str] = Field(None, description="Column description")
|
||||
text_default: Optional[str] = Field(None, description="Default text value")
|
||||
text_allowed_pattern: Optional[str] = Field(
|
||||
None, description="Allowed text pattern"
|
||||
)
|
||||
text_max_length: Optional[int] = Field(None, description="Maximum text length")
|
||||
number_default: Optional[float] = Field(None, description="Default number value")
|
||||
number_min: Optional[float] = Field(None, description="Minimum number value")
|
||||
number_max: Optional[float] = Field(None, description="Maximum number value")
|
||||
number_decimals: Optional[int] = Field(None, description="Number of decimal places")
|
||||
datetime_default: Optional[str] = Field(None, description="Default datetime value")
|
||||
selection_options: List[str] = Field(
|
||||
default_factory=list, description="Selection options"
|
||||
)
|
||||
selection_default: Optional[str] = Field(
|
||||
None, description="Default selection value"
|
||||
)
|
||||
|
||||
|
||||
class TableRow(BaseModel):
|
||||
"""Model for a table row."""
|
||||
|
||||
id: int = Field(description="Row ID")
|
||||
created_by: Optional[str] = Field(None, description="User who created the row")
|
||||
created_at: Optional[str] = Field(None, description="Row creation timestamp")
|
||||
last_edit_by: Optional[str] = Field(
|
||||
None, description="User who last edited the row"
|
||||
)
|
||||
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
|
||||
data: Dict[int, Any] = Field(description="Row data keyed by column ID")
|
||||
|
||||
|
||||
class TableView(BaseModel):
|
||||
"""Model for a table view."""
|
||||
|
||||
id: int = Field(description="View ID")
|
||||
title: str = Field(description="View title")
|
||||
emoji: Optional[str] = Field(None, description="View emoji")
|
||||
description: Optional[str] = Field(None, description="View description")
|
||||
columns: List[int] = Field(
|
||||
default_factory=list, description="List of column IDs in this view"
|
||||
)
|
||||
sort: List[Dict[str, Any]] = Field(
|
||||
default_factory=list, description="Sort configuration"
|
||||
)
|
||||
filter: List[Dict[str, Any]] = Field(
|
||||
default_factory=list, description="Filter configuration"
|
||||
)
|
||||
|
||||
|
||||
class Table(BaseModel):
|
||||
"""Model for a Nextcloud table."""
|
||||
|
||||
id: int = Field(description="Table ID")
|
||||
title: str = Field(description="Table title")
|
||||
emoji: Optional[str] = Field(None, description="Table emoji")
|
||||
ownership: str = Field(description="Table ownership")
|
||||
owner_display_name: str = Field(description="Display name of table owner")
|
||||
created_by: Optional[str] = Field(None, description="User who created the table")
|
||||
created_at: Optional[str] = Field(None, description="Table creation timestamp")
|
||||
last_edit_by: Optional[str] = Field(
|
||||
None, description="User who last edited the table"
|
||||
)
|
||||
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
|
||||
row_count: int = Field(default=0, description="Number of rows in the table")
|
||||
has_shares: bool = Field(default=False, description="Whether table is shared")
|
||||
archived: bool = Field(default=False, description="Whether table is archived")
|
||||
is_shared: bool = Field(
|
||||
default=False, description="Whether table is shared with current user"
|
||||
)
|
||||
on_share_permissions: Optional[Dict[str, Any]] = Field(
|
||||
None, description="Share permissions"
|
||||
)
|
||||
|
||||
|
||||
class TableSchema(BaseModel):
|
||||
"""Model for complete table schema including columns and views."""
|
||||
|
||||
table: Table = Field(description="Table information")
|
||||
columns: List[TableColumn] = Field(description="Table columns")
|
||||
views: List[TableView] = Field(description="Table views")
|
||||
|
||||
|
||||
class ListTablesResponse(BaseResponse):
|
||||
"""Response model for listing tables."""
|
||||
|
||||
tables: List[Table] = Field(description="List of available tables")
|
||||
total_count: int = Field(description="Total number of tables")
|
||||
|
||||
|
||||
class GetSchemaResponse(BaseResponse):
|
||||
"""Response model for getting table schema."""
|
||||
|
||||
table_schema: TableSchema = Field(description="Table schema information")
|
||||
|
||||
|
||||
class ReadTableResponse(BaseResponse):
|
||||
"""Response model for reading table rows."""
|
||||
|
||||
rows: List[TableRow] = Field(description="Table rows")
|
||||
table_id: int = Field(description="Table ID")
|
||||
total_count: Optional[int] = Field(
|
||||
None, description="Total number of rows (if known)"
|
||||
)
|
||||
offset: Optional[int] = Field(None, description="Offset used for pagination")
|
||||
limit: Optional[int] = Field(None, description="Limit used for pagination")
|
||||
|
||||
|
||||
class CreateRowResponse(IdResponse):
|
||||
"""Response model for row creation."""
|
||||
|
||||
row: TableRow = Field(description="The created row")
|
||||
table_id: int = Field(description="Table ID the row was created in")
|
||||
|
||||
|
||||
class UpdateRowResponse(BaseResponse):
|
||||
"""Response model for row updates."""
|
||||
|
||||
row: TableRow = Field(description="The updated row")
|
||||
|
||||
|
||||
class DeleteRowResponse(StatusResponse):
|
||||
"""Response model for row deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of the deleted row")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Pydantic models for WebDAV responses."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class FileInfo(BaseModel):
|
||||
"""Model for file/directory information."""
|
||||
|
||||
name: str = Field(description="File/directory name")
|
||||
path: str = Field(description="Full path")
|
||||
is_directory: bool = Field(description="Whether this is a directory")
|
||||
size: Optional[int] = Field(
|
||||
None, description="File size in bytes (None for directories)"
|
||||
)
|
||||
content_type: Optional[str] = Field(None, description="MIME content type")
|
||||
last_modified: Optional[str] = Field(
|
||||
None, description="Last modification time (ISO format)"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
|
||||
@property
|
||||
def last_modified_datetime(self) -> Optional[datetime]:
|
||||
"""Convert last modified string to datetime."""
|
||||
if not self.last_modified:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(self.last_modified.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class DirectoryListing(BaseResponse):
|
||||
"""Response model for directory listings."""
|
||||
|
||||
path: str = Field(description="Directory path")
|
||||
items: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
total_count: int = Field(description="Total number of items")
|
||||
directories_count: int = Field(description="Number of directories")
|
||||
files_count: int = Field(description="Number of files")
|
||||
total_size: int = Field(default=0, description="Total size of all files in bytes")
|
||||
|
||||
|
||||
class ReadFileResponse(BaseResponse):
|
||||
"""Response model for reading file contents."""
|
||||
|
||||
path: str = Field(description="File path")
|
||||
content: str = Field(description="File content (text or base64 for binary)")
|
||||
content_type: str = Field(description="MIME content type")
|
||||
size: int = Field(description="File size in bytes")
|
||||
encoding: Optional[str] = Field(
|
||||
None, description="Encoding used (e.g., 'base64' for binary files)"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
last_modified: Optional[str] = Field(None, description="Last modification time")
|
||||
|
||||
|
||||
class WriteFileResponse(StatusResponse):
|
||||
"""Response model for writing files."""
|
||||
|
||||
path: str = Field(description="File path that was written")
|
||||
size: Optional[int] = Field(None, description="Size of the written file")
|
||||
created: bool = Field(description="Whether a new file was created (vs overwritten)")
|
||||
|
||||
|
||||
class CreateDirectoryResponse(StatusResponse):
|
||||
"""Response model for directory creation."""
|
||||
|
||||
path: str = Field(description="Directory path that was created")
|
||||
created: bool = Field(
|
||||
description="Whether directory was created or already existed"
|
||||
)
|
||||
|
||||
|
||||
class DeleteResourceResponse(StatusResponse):
|
||||
"""Response model for resource deletion."""
|
||||
|
||||
path: str = Field(description="Path that was deleted")
|
||||
was_directory: bool = Field(
|
||||
description="Whether the deleted resource was a directory"
|
||||
)
|
||||
items_deleted: Optional[int] = Field(
|
||||
None, description="Number of items deleted (for directories)"
|
||||
)
|
||||
@@ -1,200 +0,0 @@
|
||||
# server.py
|
||||
import logging
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from collections.abc import AsyncIterator
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
setup_logging()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
client: NextcloudClient
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
logging.info("Creating Nextcloud client")
|
||||
client = NextcloudClient.from_env()
|
||||
logging.info("Client initialization wait complete.")
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
await client.close()
|
||||
|
||||
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.capabilities()
|
||||
|
||||
|
||||
@mcp.resource("notes://settings")
|
||||
async def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.get_settings()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_get_note(note_id: int, ctx: Context):
|
||||
"""Get user note using note id"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.get_note(note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None,
|
||||
content: str | None,
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
):
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
|
||||
"""Append content to an existing note with a clear separator"""
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.append_content(note_id=note_id, content=content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_search_notes(query: str, ctx: Context):
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_search_notes(query=query)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context):
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.list_tables()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
ctx: Context = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.delete_row(row_id)
|
||||
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
# Use uppercase 'Notes' to match the decorator
|
||||
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
|
||||
"mimeType": mime_type, # Client needs to determine this
|
||||
"data": content, # Return raw bytes/data
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def run():
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting now")
|
||||
mcp.run()
|
||||
@@ -0,0 +1,13 @@
|
||||
from .calendar import configure_calendar_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .tables import configure_tables_tools
|
||||
from .webdav import configure_webdav_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
|
||||
__all__ = [
|
||||
"configure_calendar_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_tables_tools",
|
||||
"configure_webdav_tools",
|
||||
"configure_contacts_tools",
|
||||
]
|
||||
@@ -0,0 +1,801 @@
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
ListCalendarsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
calendars_data = await client.calendar.list_calendars()
|
||||
|
||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_create_event(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
start_datetime: str,
|
||||
ctx: Context,
|
||||
end_datetime: str = "",
|
||||
all_day: bool = False,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
categories: str = "",
|
||||
recurring: bool = False,
|
||||
recurrence_rule: str = "",
|
||||
recurrence_end_date: str = "",
|
||||
reminder_minutes: int = 15,
|
||||
reminder_email: bool = False,
|
||||
status: str = "CONFIRMED",
|
||||
priority: int = 5,
|
||||
privacy: str = "PUBLIC",
|
||||
attendees: str = "",
|
||||
url: str = "",
|
||||
color: str = "",
|
||||
):
|
||||
"""Create a comprehensive calendar event with full feature support
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to create the event in
|
||||
title: Event title
|
||||
start_datetime: ISO format: "2025-01-15T14:00:00" or "2025-01-15" for all-day
|
||||
ctx: MCP context
|
||||
end_datetime: ISO format end time, empty for all-day events
|
||||
all_day: Whether this is an all-day event
|
||||
description: Event description/details
|
||||
location: Event location
|
||||
categories: Comma-separated categories (e.g., "work,meeting")
|
||||
recurring: Whether this is a recurring event
|
||||
recurrence_rule: RFC5545 RRULE (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR")
|
||||
recurrence_end_date: When to stop recurring
|
||||
reminder_minutes: Minutes before event to send reminder
|
||||
reminder_email: Whether to send email notification
|
||||
status: Event status: CONFIRMED, TENTATIVE, or CANCELLED
|
||||
priority: Priority level 1-9 (1=highest, 9=lowest, 5=normal)
|
||||
privacy: Privacy level: PUBLIC, PRIVATE, or CONFIDENTIAL
|
||||
attendees: Comma-separated email addresses
|
||||
url: Related URL for the event
|
||||
color: Event color (hex or name)
|
||||
|
||||
Returns:
|
||||
Dict with event creation result
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
event_data = {
|
||||
"title": title,
|
||||
"start_datetime": start_datetime,
|
||||
"end_datetime": end_datetime,
|
||||
"all_day": all_day,
|
||||
"description": description,
|
||||
"location": location,
|
||||
"categories": categories,
|
||||
"recurring": recurring,
|
||||
"recurrence_rule": recurrence_rule,
|
||||
"recurrence_end_date": recurrence_end_date,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"reminder_email": reminder_email,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"privacy": privacy,
|
||||
"attendees": attendees,
|
||||
"url": url,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_list_events(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
limit: int = 50,
|
||||
min_attendees: Optional[int] = None,
|
||||
min_duration_minutes: Optional[int] = None,
|
||||
categories: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
title_contains: Optional[str] = None,
|
||||
location_contains: Optional[str] = None,
|
||||
search_all_calendars: bool = False,
|
||||
):
|
||||
"""List events in a calendar (or all calendars) within date range with advanced filtering.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to search. Ignored if search_all_calendars=True.
|
||||
ctx: MCP context
|
||||
start_date: Start date for search (YYYY-MM-DD format, e.g., "2025-01-01")
|
||||
end_date: End date for search (YYYY-MM-DD format, e.g., "2025-01-31")
|
||||
limit: Maximum number of events to return
|
||||
min_attendees: Filter events with at least this many attendees
|
||||
min_duration_minutes: Filter events with at least this duration
|
||||
categories: Filter events containing any of these categories (comma-separated, e.g., "work,meeting")
|
||||
status: Filter events by status (CONFIRMED, TENTATIVE, or CANCELLED)
|
||||
title_contains: Filter events where title contains this text
|
||||
location_contains: Filter events where location contains this text
|
||||
search_all_calendars: If True, search across all calendars instead of just one
|
||||
|
||||
Returns:
|
||||
List of events matching the filters
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Convert YYYY-MM-DD format dates to datetime objects
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
# If parsing fails, try to parse as ISO format
|
||||
try:
|
||||
start_datetime = dt.datetime.fromisoformat(start_date)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid start_date format: {start_date}")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
# For end date, set to end of day (23:59:59)
|
||||
end_datetime = dt.datetime.strptime(end_date, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
# If parsing fails, try to parse as ISO format
|
||||
try:
|
||||
end_datetime = dt.datetime.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid end_date format: {end_date}")
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
if min_attendees is not None:
|
||||
filters["min_attendees"] = min_attendees
|
||||
if min_duration_minutes is not None:
|
||||
filters["min_duration_minutes"] = min_duration_minutes
|
||||
if categories is not None:
|
||||
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||
if status is not None:
|
||||
filters["status"] = status
|
||||
if title_contains is not None:
|
||||
filters["title_contains"] = title_contains
|
||||
if location_contains is not None:
|
||||
filters["location_contains"] = location_contains
|
||||
|
||||
if search_all_calendars:
|
||||
# Search across all calendars with filters
|
||||
events = await client.calendar.search_events_across_calendars(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
return events[:limit]
|
||||
else:
|
||||
# Search in specific calendar
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
events = client.calendar._apply_event_filters(events, filters)
|
||||
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_get_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
ctx: Context,
|
||||
):
|
||||
"""Get detailed information about a specific event"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_update_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
ctx: Context,
|
||||
# All the same parameters as create_event but optional
|
||||
title: str | None = None,
|
||||
start_datetime: str | None = None,
|
||||
end_datetime: str | None = None,
|
||||
all_day: bool | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
categories: str | None = None,
|
||||
# Recurrence updates
|
||||
recurring: bool | None = None,
|
||||
recurrence_rule: str | None = None,
|
||||
# Notification updates
|
||||
reminder_minutes: int | None = None,
|
||||
reminder_email: bool | None = None,
|
||||
# Event property updates
|
||||
status: str | None = None,
|
||||
priority: int | None = None,
|
||||
privacy: str | None = None,
|
||||
attendees: str | None = None,
|
||||
url: str | None = None,
|
||||
color: str | None = None,
|
||||
etag: str = "",
|
||||
):
|
||||
"""Update any aspect of an existing event"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Build update data with only non-None values
|
||||
event_data = {}
|
||||
if title is not None:
|
||||
event_data["title"] = title
|
||||
if start_datetime is not None:
|
||||
event_data["start_datetime"] = start_datetime
|
||||
if end_datetime is not None:
|
||||
event_data["end_datetime"] = end_datetime
|
||||
if all_day is not None:
|
||||
event_data["all_day"] = all_day
|
||||
if description is not None:
|
||||
event_data["description"] = description
|
||||
if location is not None:
|
||||
event_data["location"] = location
|
||||
if categories is not None:
|
||||
event_data["categories"] = categories
|
||||
if recurring is not None:
|
||||
event_data["recurring"] = recurring
|
||||
if recurrence_rule is not None:
|
||||
event_data["recurrence_rule"] = recurrence_rule
|
||||
if reminder_minutes is not None:
|
||||
event_data["reminder_minutes"] = reminder_minutes
|
||||
if reminder_email is not None:
|
||||
event_data["reminder_email"] = reminder_email
|
||||
if status is not None:
|
||||
event_data["status"] = status
|
||||
if priority is not None:
|
||||
event_data["priority"] = priority
|
||||
if privacy is not None:
|
||||
event_data["privacy"] = privacy
|
||||
if attendees is not None:
|
||||
event_data["attendees"] = attendees
|
||||
if url is not None:
|
||||
event_data["url"] = url
|
||||
if color is not None:
|
||||
event_data["color"] = color
|
||||
|
||||
return await client.calendar.update_event(
|
||||
calendar_name, event_uid, event_data, etag
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_delete_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
ctx: Context,
|
||||
):
|
||||
"""Delete a calendar event"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_create_meeting(
|
||||
title: str,
|
||||
date: str,
|
||||
time: str,
|
||||
ctx: Context,
|
||||
duration_minutes: int = 60,
|
||||
calendar_name: str = "personal",
|
||||
attendees: str = "",
|
||||
location: str = "",
|
||||
description: str = "",
|
||||
reminder_minutes: int = 15,
|
||||
):
|
||||
"""Quick meeting creation with smart defaults
|
||||
|
||||
This is a convenience function for creating events with common meeting defaults.
|
||||
It automatically:
|
||||
- Calculates end time based on duration
|
||||
- Sets status to CONFIRMED
|
||||
- Adds a reminder
|
||||
- Uses simpler date/time inputs instead of full ISO format
|
||||
|
||||
For full control over all event properties, use nc_calendar_create_event instead.
|
||||
|
||||
Args:
|
||||
title: Meeting title
|
||||
date: Meeting date (YYYY-MM-DD format, e.g., "2025-01-15")
|
||||
time: Meeting start time (HH:MM format, e.g., "14:00")
|
||||
ctx: MCP context
|
||||
duration_minutes: Meeting duration in minutes (default: 60)
|
||||
calendar_name: Calendar to create the meeting in (default: "personal")
|
||||
attendees: Comma-separated email addresses of attendees
|
||||
location: Meeting location
|
||||
description: Meeting description/agenda
|
||||
reminder_minutes: Minutes before meeting to send reminder (default: 15)
|
||||
|
||||
Returns:
|
||||
Dict with meeting creation result
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Combine date and time for start_datetime
|
||||
start_datetime = f"{date}T{time}:00"
|
||||
|
||||
# Calculate end_datetime
|
||||
start_dt = dt.datetime.fromisoformat(start_datetime)
|
||||
end_dt = start_dt + dt.timedelta(minutes=duration_minutes)
|
||||
end_datetime = end_dt.isoformat()
|
||||
|
||||
event_data = {
|
||||
"title": title,
|
||||
"start_datetime": start_datetime,
|
||||
"end_datetime": end_datetime,
|
||||
"all_day": False,
|
||||
"description": description,
|
||||
"location": location,
|
||||
"attendees": attendees,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
"privacy": "PUBLIC",
|
||||
}
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
ctx: Context,
|
||||
calendar_name: str = "", # Empty = all calendars
|
||||
days_ahead: int = 7,
|
||||
limit: int = 10,
|
||||
):
|
||||
"""Get upcoming events in next N days"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
now = dt.datetime.now()
|
||||
end_datetime = now + dt.timedelta(days=days_ahead)
|
||||
|
||||
if calendar_name:
|
||||
# Get events from specific calendar
|
||||
return await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
else:
|
||||
# Get events from all calendars
|
||||
all_calendars = await client.calendar.list_calendars()
|
||||
all_events = []
|
||||
|
||||
for calendar in all_calendars:
|
||||
try:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar["name"],
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# Add calendar info to each event
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar["name"]
|
||||
event["calendar_display_name"] = calendar["display_name"]
|
||||
all_events.extend(events)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Sort by start time and limit
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_find_availability(
|
||||
duration_minutes: int,
|
||||
ctx: Context,
|
||||
attendees: str = "", # Comma-separated email list
|
||||
date_range_start: str = "", # "2025-07-28"
|
||||
date_range_end: str = "", # "2025-08-04"
|
||||
business_hours_only: bool = True,
|
||||
exclude_weekends: bool = True,
|
||||
preferred_times: str = "", # Comma-separated time ranges like "09:00-12:00,14:00-17:00"
|
||||
):
|
||||
"""Find available time slots for scheduling meetings.
|
||||
|
||||
This tool intelligently analyzes existing calendar events to find free time slots
|
||||
that work for all specified attendees within the given constraints.
|
||||
|
||||
Args:
|
||||
duration_minutes: Required duration for the meeting in minutes
|
||||
attendees: Comma-separated list of attendee email addresses to check availability for
|
||||
date_range_start: Start date for availability search (YYYY-MM-DD)
|
||||
date_range_end: End date for availability search (YYYY-MM-DD)
|
||||
business_hours_only: Only suggest slots during business hours (9 AM - 5 PM)
|
||||
exclude_weekends: Skip weekends when finding availability
|
||||
preferred_times: Preferred time ranges as "HH:MM-HH:MM" (comma-separated)
|
||||
|
||||
Returns:
|
||||
List of available time slots with start/end times and duration
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Parse attendees
|
||||
attendee_list = []
|
||||
if attendees:
|
||||
attendee_list = [
|
||||
email.strip() for email in attendees.split(",") if email.strip()
|
||||
]
|
||||
|
||||
# Parse preferred times
|
||||
preferred_time_list = []
|
||||
if preferred_times:
|
||||
preferred_time_list = [
|
||||
time_range.strip()
|
||||
for time_range in preferred_times.split(",")
|
||||
if time_range.strip()
|
||||
]
|
||||
|
||||
# Convert date strings to datetime objects
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
|
||||
if date_range_start:
|
||||
try:
|
||||
start_datetime = dt.datetime.strptime(date_range_start, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date_range_start format: {date_range_start}")
|
||||
|
||||
if date_range_end:
|
||||
try:
|
||||
end_datetime = dt.datetime.strptime(date_range_end, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date_range_end format: {date_range_end}")
|
||||
|
||||
# Build constraints
|
||||
constraints = {
|
||||
"business_hours_only": business_hours_only,
|
||||
"exclude_weekends": exclude_weekends,
|
||||
"preferred_times": preferred_time_list,
|
||||
}
|
||||
|
||||
return await client.calendar.find_availability(
|
||||
duration_minutes=duration_minutes,
|
||||
attendees=attendee_list,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
constraints=constraints,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_bulk_operations(
|
||||
operation: str, # "update", "delete", "move"
|
||||
ctx: Context,
|
||||
title_contains: Optional[str] = None,
|
||||
categories: Optional[str] = None, # Comma-separated
|
||||
calendar_name: Optional[str] = None,
|
||||
start_date: str = "", # "2025-07-01"
|
||||
end_date: str = "", # "2025-07-31"
|
||||
status: Optional[str] = None,
|
||||
location_contains: Optional[str] = None,
|
||||
# Update operation parameters
|
||||
new_title: Optional[str] = None,
|
||||
new_description: Optional[str] = None,
|
||||
new_location: Optional[str] = None,
|
||||
new_categories: Optional[str] = None,
|
||||
new_priority: Optional[int] = None,
|
||||
new_reminder_minutes: Optional[int] = None,
|
||||
# Move operation parameters
|
||||
target_calendar: Optional[str] = None,
|
||||
):
|
||||
"""Perform bulk operations (update/delete) on events matching filter criteria.
|
||||
|
||||
This tool allows you to efficiently modify or delete multiple events at once
|
||||
by applying filters to find matching events and then performing the specified operation.
|
||||
|
||||
Args:
|
||||
operation: Type of operation - "update" or "delete"
|
||||
title_contains: Filter events where title contains this text
|
||||
categories: Filter events containing any of these categories (comma-separated)
|
||||
calendar_name: Filter events from this specific calendar
|
||||
start_date: Filter events starting from this date (YYYY-MM-DD)
|
||||
end_date: Filter events ending before this date (YYYY-MM-DD)
|
||||
status: Filter events by status (CONFIRMED, TENTATIVE, CANCELLED)
|
||||
location_contains: Filter events where location contains this text
|
||||
|
||||
# For update operations:
|
||||
new_title: New title for matching events
|
||||
new_description: New description for matching events
|
||||
new_location: New location for matching events
|
||||
new_categories: New categories for matching events (comma-separated)
|
||||
new_priority: New priority for matching events (1-9, 5=normal)
|
||||
new_reminder_minutes: New reminder time in minutes before event
|
||||
|
||||
# For move operations:
|
||||
target_calendar: Calendar to move events to (requires operation="move")
|
||||
|
||||
Returns:
|
||||
Summary of operation results including counts and details
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
if operation not in ["update", "delete", "move"]:
|
||||
raise ValueError("Operation must be 'update', 'delete', or 'move'")
|
||||
|
||||
# Convert date strings to datetime objects
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid start_date format: {start_date}")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_datetime = dt.datetime.strptime(end_date, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid end_date format: {end_date}")
|
||||
|
||||
# Build filter criteria
|
||||
filter_criteria = {}
|
||||
if title_contains is not None:
|
||||
filter_criteria["title_contains"] = title_contains
|
||||
if categories is not None:
|
||||
filter_criteria["categories"] = [
|
||||
cat.strip() for cat in categories.split(",")
|
||||
]
|
||||
if status is not None:
|
||||
filter_criteria["status"] = status
|
||||
if location_contains is not None:
|
||||
filter_criteria["location_contains"] = location_contains
|
||||
# Add datetime strings for client compatibility
|
||||
if start_date:
|
||||
filter_criteria["start_date"] = start_date
|
||||
if end_date:
|
||||
filter_criteria["end_date"] = end_date
|
||||
|
||||
if operation == "delete":
|
||||
# Find matching events and delete them
|
||||
if calendar_name:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
)
|
||||
if filter_criteria:
|
||||
events = client.calendar._apply_event_filters(
|
||||
events, filter_criteria
|
||||
)
|
||||
else:
|
||||
events = await client.calendar.search_events_across_calendars(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
filters=filter_criteria,
|
||||
)
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
results = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
await client.calendar.delete_event(
|
||||
event.get("calendar_name", calendar_name), event["uid"]
|
||||
)
|
||||
deleted_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "deleted",
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"operation": "delete",
|
||||
"total_found": len(events),
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": failed_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
elif operation == "update":
|
||||
# Build update data
|
||||
update_data = {}
|
||||
if new_title is not None:
|
||||
update_data["title"] = new_title
|
||||
if new_description is not None:
|
||||
update_data["description"] = new_description
|
||||
if new_location is not None:
|
||||
update_data["location"] = new_location
|
||||
if new_categories is not None:
|
||||
update_data["categories"] = new_categories
|
||||
if new_priority is not None:
|
||||
update_data["priority"] = new_priority
|
||||
if new_reminder_minutes is not None:
|
||||
update_data["reminder_minutes"] = new_reminder_minutes
|
||||
|
||||
if not update_data:
|
||||
raise ValueError("No update data provided for update operation")
|
||||
|
||||
return await client.calendar.bulk_update_events(
|
||||
filter_criteria, update_data
|
||||
)
|
||||
|
||||
elif operation == "move":
|
||||
if not target_calendar:
|
||||
raise ValueError("target_calendar is required for move operation")
|
||||
|
||||
# Find matching events
|
||||
if calendar_name:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
)
|
||||
if filter_criteria:
|
||||
events = client.calendar._apply_event_filters(
|
||||
events, filter_criteria
|
||||
)
|
||||
else:
|
||||
events = await client.calendar.search_events_across_calendars(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
filters=filter_criteria,
|
||||
)
|
||||
|
||||
moved_count = 0
|
||||
failed_count = 0
|
||||
results = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
# Create event in target calendar
|
||||
event_data = {
|
||||
k: v
|
||||
for k, v in event.items()
|
||||
if k
|
||||
not in [
|
||||
"uid",
|
||||
"href",
|
||||
"etag",
|
||||
"calendar_name",
|
||||
"calendar_display_name",
|
||||
]
|
||||
}
|
||||
|
||||
await client.calendar.create_event(target_calendar, event_data)
|
||||
|
||||
# Delete from source calendar
|
||||
await client.calendar.delete_event(
|
||||
event.get("calendar_name", calendar_name), event["uid"]
|
||||
)
|
||||
|
||||
moved_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "moved",
|
||||
"title": event.get("title", ""),
|
||||
"from_calendar": event.get("calendar_name", calendar_name),
|
||||
"to_calendar": target_calendar,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
results.append(
|
||||
{
|
||||
"uid": event["uid"],
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"title": event.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"operation": "move",
|
||||
"total_found": len(events),
|
||||
"moved_count": moved_count,
|
||||
"failed_count": failed_count,
|
||||
"target_calendar": target_calendar,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_calendar_manage_calendar(
|
||||
action: str, # "create", "delete", "update", "list"
|
||||
ctx: Context,
|
||||
calendar_name: str = "",
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
color: str = "#1976D2", # Default blue color
|
||||
):
|
||||
"""Manage calendar creation, deletion, and properties.
|
||||
|
||||
This tool provides comprehensive calendar management functionality including
|
||||
creating new calendars, deleting existing ones, and updating calendar properties.
|
||||
|
||||
Args:
|
||||
action: Action to perform - "create", "delete", "update", or "list"
|
||||
calendar_name: Internal name for the calendar (required for create/delete/update)
|
||||
display_name: Human-readable name for the calendar (used for create/update)
|
||||
description: Description for the calendar (used for create/update)
|
||||
color: Hex color code for the calendar (e.g., "#1976D2" for blue)
|
||||
|
||||
Returns:
|
||||
Result of the calendar management operation
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
if action == "list":
|
||||
return await client.calendar.list_calendars()
|
||||
|
||||
elif action == "create":
|
||||
if not calendar_name:
|
||||
raise ValueError("calendar_name is required for create action")
|
||||
|
||||
return await client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=display_name or calendar_name,
|
||||
description=description,
|
||||
color=color,
|
||||
)
|
||||
|
||||
elif action == "delete":
|
||||
if not calendar_name:
|
||||
raise ValueError("calendar_name is required for delete action")
|
||||
|
||||
return await client.calendar.delete_calendar(calendar_name)
|
||||
|
||||
elif action == "update":
|
||||
if not calendar_name:
|
||||
raise ValueError("calendar_name is required for update action")
|
||||
|
||||
# Note: Calendar property updates require additional CalDAV PROPPATCH implementation
|
||||
# For now, return an informative message
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Calendar property updates require PROPPATCH implementation",
|
||||
"calendar_name": calendar_name,
|
||||
"requested_changes": {
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
"color": color,
|
||||
},
|
||||
}
|
||||
|
||||
else:
|
||||
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_create_addressbook(
|
||||
ctx: Context, *, name: str, display_name: str
|
||||
):
|
||||
"""Create a new addressbook.
|
||||
|
||||
Args:
|
||||
name: The name of the addressbook.
|
||||
display_name: The display name of the addressbook.
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.create_addressbook(
|
||||
name=name, display_name=display_name
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_create_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||
):
|
||||
"""Create a new contact.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook to create the contact in.
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.create_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_contacts_update_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
):
|
||||
"""Update an existing contact while preserving all existing properties.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook containing the contact.
|
||||
uid: The unique ID of the contact to update.
|
||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||
etag: Optional ETag for optimistic concurrency control.
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.contacts.update_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
|
||||
)
|
||||
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.base import ErrorResponse
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
Note,
|
||||
NotesSettings,
|
||||
CreateNoteResponse,
|
||||
UpdateNoteResponse,
|
||||
DeleteNoteResponse,
|
||||
AppendContentResponse,
|
||||
SearchNotesResponse,
|
||||
NoteSearchResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_notes_tools(mcp: FastMCP):
|
||||
@mcp.resource("notes://settings")
|
||||
async def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
settings_data = await client.notes.get_settings()
|
||||
return NotesSettings(**settings_data)
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
# Use uppercase 'Notes' to match the decorator
|
||||
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
|
||||
"mimeType": mime_type, # Client needs to determine this
|
||||
"data": content, # Return raw bytes/data
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}")
|
||||
async def nc_get_note(note_id: int):
|
||||
"""Get user note using note id"""
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
ctx: Context = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Access denied to note {note_id}")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve note {note_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse | ErrorResponse:
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
note = Note(**note_data)
|
||||
return CreateNoteResponse(id=note.id, note=note)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error="Access denied: insufficient permissions to create notes"
|
||||
)
|
||||
elif e.response.status_code == 413:
|
||||
return ErrorResponse(error="Note content too large")
|
||||
elif e.response.status_code == 409:
|
||||
return ErrorResponse(
|
||||
error=f"A note with title '{title}' already exists in this category"
|
||||
)
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Failed to create note: server error ({e.response.status_code})"
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None,
|
||||
content: str | None,
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse | ErrorResponse:
|
||||
"""Update an existing note's title, content, or category"""
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
note_data = await client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
note = Note(**note_data)
|
||||
return UpdateNoteResponse(note=note)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return ErrorResponse(error=f"Note {note_id} not found")
|
||||
elif e.response.status_code == 412:
|
||||
return ErrorResponse(
|
||||
error=f"Note {note_id} has been modified by someone else. Please refresh and try again."
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error=f"Access denied: insufficient permissions to update note {note_id}"
|
||||
)
|
||||
elif e.response.status_code == 413:
|
||||
return ErrorResponse(error="Updated note content is too large")
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Failed to update note {note_id}: server error ({e.response.status_code})"
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_append_content(
|
||||
note_id: int, content: str, ctx: Context
|
||||
) -> AppendContentResponse | ErrorResponse:
|
||||
"""Append content to an existing note with a clear separator"""
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
note_data = await client.notes.append_content(
|
||||
note_id=note_id, content=content
|
||||
)
|
||||
note = Note(**note_data)
|
||||
return AppendContentResponse(note=note)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return ErrorResponse(error=f"Note {note_id} not found")
|
||||
elif e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error=f"Access denied: insufficient permissions to modify note {note_id}"
|
||||
)
|
||||
elif e.response.status_code == 413:
|
||||
return ErrorResponse(
|
||||
error="Content to append would make the note too large"
|
||||
)
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Failed to append content to note {note_id}: server error ({e.response.status_code})"
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_search_notes(
|
||||
query: str, ctx: Context
|
||||
) -> SearchNotesResponse | ErrorResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
|
||||
# Convert to NoteSearchResult models, including the _score field
|
||||
results = [
|
||||
NoteSearchResult(
|
||||
id=result["id"],
|
||||
title=result["title"],
|
||||
category=result["category"],
|
||||
score=result.get("_score"), # Include search score if available
|
||||
)
|
||||
for result in search_results_raw
|
||||
]
|
||||
|
||||
return SearchNotesResponse(
|
||||
results=results, query=query, total_found=len(results)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error="Access denied: insufficient permissions to search notes"
|
||||
)
|
||||
elif e.response.status_code == 400:
|
||||
return ErrorResponse(error="Invalid search query format")
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Search failed: server error ({e.response.status_code})"
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_delete_note(
|
||||
note_id: int, ctx: Context
|
||||
) -> DeleteNoteResponse | ErrorResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
await client.notes.delete_note(note_id)
|
||||
return DeleteNoteResponse(
|
||||
status_code=200,
|
||||
message=f"Note {note_id} deleted successfully",
|
||||
deleted_id=note_id,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return ErrorResponse(error=f"Note {note_id} not found")
|
||||
elif e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error=f"Access denied: insufficient permissions to delete note {note_id}"
|
||||
)
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Failed to delete note {note_id}: server error ({e.response.status_code})"
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
ctx: Context,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.delete_row(row_id)
|
||||
@@ -0,0 +1,151 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
Returns:
|
||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
||||
|
||||
Examples:
|
||||
# List root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# List a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.list_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path to the file to read
|
||||
|
||||
Returns:
|
||||
Dict with path, content, content_type, size, and encoding (if binary)
|
||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
print(result['content']) # Decoded text content
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
print(result['encoding']) # 'base64'
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# For text files, decode content for easier viewing
|
||||
if content_type and content_type.startswith("text/"):
|
||||
try:
|
||||
decoded_content = content.decode("utf-8")
|
||||
return {
|
||||
"path": path,
|
||||
"content": decoded_content,
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
}
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
# For binary files, return metadata and base64 encoded content
|
||||
import base64
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"content": base64.b64encode(content).decode("ascii"),
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_write_file(
|
||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||
):
|
||||
"""Write content to a file in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path where to write the file
|
||||
content: File content (text or base64 for binary)
|
||||
content_type: MIME type (auto-detected if not provided, use 'type;base64' for binary)
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating success
|
||||
|
||||
Examples:
|
||||
# Write a text file
|
||||
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
|
||||
|
||||
# Write binary data (base64 encoded)
|
||||
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Handle base64 encoded content
|
||||
if content_type and "base64" in content_type.lower():
|
||||
import base64
|
||||
|
||||
content_bytes = base64.b64decode(content)
|
||||
content_type = content_type.replace(";base64", "")
|
||||
else:
|
||||
content_bytes = content.encode("utf-8")
|
||||
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path of the directory to create
|
||||
|
||||
Returns:
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
|
||||
Examples:
|
||||
# Create a single directory
|
||||
await nc_webdav_create_directory("NewProject")
|
||||
|
||||
# Create nested directories (parent must exist)
|
||||
await nc_webdav_create_directory("Projects/MyApp/docs")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path of the file or directory to delete
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
|
||||
Examples:
|
||||
# Delete a file
|
||||
await nc_webdav_delete_resource("old_document.txt")
|
||||
|
||||
# Delete a directory (will delete all contents)
|
||||
await nc_webdav_delete_resource("temp_folder")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.delete_resource(path)
|
||||
@@ -1,244 +0,0 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import mimetypes
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from .base_client import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud WebDAV operations."""
|
||||
|
||||
async def delete_resource(self, path: str) -> Dict[str, Any]:
|
||||
"""Delete a resource (file or directory) via WebDAV DELETE."""
|
||||
# Ensure path ends with a slash if it's a directory
|
||||
if not path.endswith("/"):
|
||||
path_with_slash = f"{path}/"
|
||||
else:
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.info(f"Deleting WebDAV resource: {webdav_path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First try a PROPFIND to verify resource exists
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await self._client.request(
|
||||
"PROPFIND", webdav_path, headers=propfind_headers
|
||||
)
|
||||
logger.info(
|
||||
f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.info(
|
||||
f"Resource '{webdav_path}' doesn't exist, no deletion needed."
|
||||
)
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = await self._client.delete(webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully deleted WebDAV resource '{webdav_path}' (Status: {response.status_code})"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.warning(f"HTTP error deleting WebDAV resource '{webdav_path}': {e}")
|
||||
if e.response.status_code != 404:
|
||||
raise e
|
||||
else:
|
||||
logger.info(f"Resource '{webdav_path}' not found, no deletion needed.")
|
||||
return {"status_code": 404}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error deleting WebDAV resource '{webdav_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def cleanup_old_attachment_directory(
|
||||
self, note_id: int, old_category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up the attachment directory for a note in its old category location."""
|
||||
old_category_path_part = f"{old_category}/" if old_category else ""
|
||||
old_attachment_dir_path = (
|
||||
f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_note_attachments(
|
||||
self, note_id: int, category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Clean up attachment directory for a specific note and category."""
|
||||
cat_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.info(
|
||||
f"Attempting to delete attachment directory for note {note_id} in category '{category}' via WebDAV: {attachment_dir_path}"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.info(
|
||||
f"WebDAV deletion for category '{category}' attachment directory: {delete_result}"
|
||||
)
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed during WebDAV deletion for category '{category}' attachment directory: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def add_note_attachment(
|
||||
self,
|
||||
note_id: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
category: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add/Update an attachment to a note via WebDAV PUT."""
|
||||
# Construct paths based on provided category
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
parent_dir_webdav_rel_path = (
|
||||
f"Notes/{category_path_part}{attachment_dir_segment}"
|
||||
)
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
|
||||
attachment_path = f"{parent_dir_path}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}"
|
||||
)
|
||||
|
||||
# Log current auth settings
|
||||
logger.info(
|
||||
f"WebDAV auth settings - Username: {self.username}, Auth Type: {type(self._client.auth).__name__}"
|
||||
)
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First check if we can access WebDAV at all
|
||||
notes_dir_path = f"{webdav_base}/Notes"
|
||||
logger.info(f"Testing WebDAV access to Notes directory: {notes_dir_path}")
|
||||
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
notes_dir_response = await self._client.request(
|
||||
"PROPFIND", notes_dir_path, headers=propfind_headers
|
||||
)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error(
|
||||
"WebDAV authentication failed for Notes directory. Please verify WebDAV permissions."
|
||||
)
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
response=notes_dir_response,
|
||||
)
|
||||
elif notes_dir_response.status_code >= 400:
|
||||
logger.error(
|
||||
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
|
||||
)
|
||||
notes_dir_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Successfully accessed WebDAV Notes directory (Status: {notes_dir_response.status_code})"
|
||||
)
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
logger.info(f"Ensuring attachments directory exists: {parent_dir_path}")
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
mkcol_response = await self._client.request(
|
||||
"MKCOL", parent_dir_path, headers=mkcol_headers
|
||||
)
|
||||
|
||||
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.warning(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Created/verified directory: {parent_dir_path} (Status: {mkcol_response.status_code})"
|
||||
)
|
||||
|
||||
# Proceed with the PUT request
|
||||
logger.info(f"Putting attachment file to: {attachment_path}")
|
||||
response = await self._client.put(
|
||||
attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id} (Status: {response.status_code})"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error uploading attachment '{filename}' to note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def get_note_attachment(
|
||||
self, note_id: int, filename: str, category: Optional[str] = None
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Fetch a specific attachment from a note via WebDAV GET."""
|
||||
webdav_base = self._get_webdav_base_path()
|
||||
category_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.get(attachment_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.info(
|
||||
f"Successfully fetched attachment '{filename}' ({mime_type}, {len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.3.0"
|
||||
version = "0.7.2"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
@@ -8,21 +8,21 @@ authors = [
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.9,<1.10)",
|
||||
"mcp[cli] (>=1.10,<1.11)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)"
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nc-mcp-server = "nextcloud_mcp_server.server:run"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
log_cli = 1
|
||||
log_cli_level = "WARN"
|
||||
log_level = "WARN"
|
||||
log_cli_level = "INFO"
|
||||
log_level = "INFO"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
|
||||
]
|
||||
|
||||
-10133
File diff suppressed because it is too large
Load Diff
+134
-10
@@ -1,18 +1,20 @@
|
||||
import pytest
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
import asyncio
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# pytestmark = pytest.mark.asyncio(loop_scope="package")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_client() -> NextcloudClient:
|
||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
Uses environment variables for configuration.
|
||||
@@ -29,10 +31,54 @@ async def nc_client() -> NextcloudClient:
|
||||
logger.info(
|
||||
"NextcloudClient session fixture initialized and capabilities checked."
|
||||
)
|
||||
yield client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize NextcloudClient session fixture: {e}")
|
||||
pytest.fail(f"Failed to connect to Nextcloud or get capabilities: {e}")
|
||||
return client
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests.
|
||||
"""
|
||||
logger.info("Creating SSE client")
|
||||
sse_context = sse_client(url="http://127.0.0.1:8000/sse")
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read, write = await sse_context.__aenter__()
|
||||
session_context = ClientSession(read, write)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("MCP 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 session: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
|
||||
try:
|
||||
await sse_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 SSE client: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing SSE client: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -41,7 +87,6 @@ async def temporary_note(nc_client: NextcloudClient):
|
||||
Fixture to create a temporary note for a test and ensure its deletion afterward.
|
||||
Yields the created note dictionary.
|
||||
"""
|
||||
asyncio.new_event_loop()
|
||||
|
||||
note_id = None
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
@@ -87,7 +132,6 @@ async def temporary_note_with_attachment(
|
||||
Yields a tuple: (note_data, attachment_filename, attachment_content).
|
||||
Depends on the temporary_note fixture.
|
||||
"""
|
||||
asyncio.new_event_loop()
|
||||
|
||||
note_data = temporary_note
|
||||
note_id = note_data["id"]
|
||||
@@ -126,3 +170,83 @@ async def temporary_note_with_attachment(
|
||||
|
||||
# Note: The temporary_note fixture's finally block will handle note deletion,
|
||||
# which should also trigger the WebDAV directory deletion attempt.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def temporary_addressbook(nc_client: NextcloudClient):
|
||||
"""
|
||||
Fixture to create a temporary addressbook for a test and ensure its deletion afterward.
|
||||
Yields the created addressbook dictionary.
|
||||
"""
|
||||
addressbook_name = f"test-addressbook-{uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Creating temporary addressbook: {addressbook_name}")
|
||||
try:
|
||||
await nc_client.contacts.create_addressbook(
|
||||
name=addressbook_name, display_name=f"Test Addressbook {addressbook_name}"
|
||||
)
|
||||
logger.info(f"Temporary addressbook created: {addressbook_name}")
|
||||
yield addressbook_name
|
||||
finally:
|
||||
logger.info(f"Cleaning up temporary addressbook: {addressbook_name}")
|
||||
try:
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
logger.info(
|
||||
f"Successfully deleted temporary addressbook: {addressbook_name}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(
|
||||
f"HTTP error deleting temporary addressbook {addressbook_name}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary addressbook {addressbook_name} already deleted (404)."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary addressbook {addressbook_name}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: str):
|
||||
"""
|
||||
Fixture to create a temporary contact in a temporary addressbook and ensure its deletion.
|
||||
Yields the created contact's UID.
|
||||
"""
|
||||
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
|
||||
addressbook_name = temporary_addressbook
|
||||
contact_data = {
|
||||
"fn": "John Doe",
|
||||
"email": "john.doe@example.com",
|
||||
"tel": "1234567890",
|
||||
}
|
||||
logger.info(f"Creating temporary contact in addressbook: {addressbook_name}")
|
||||
try:
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=contact_data,
|
||||
)
|
||||
logger.info(f"Temporary contact created with UID: {contact_uid}")
|
||||
yield contact_uid
|
||||
finally:
|
||||
logger.info(f"Cleaning up temporary contact: {contact_uid}")
|
||||
try:
|
||||
await nc_client.contacts.delete_contact(
|
||||
addressbook=addressbook_name, uid=contact_uid
|
||||
)
|
||||
logger.info(f"Successfully deleted temporary contact: {contact_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(
|
||||
f"HTTP error deleting temporary contact {contact_uid}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary contact {contact_uid} already deleted (404)."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary contact {contact_uid}: {e}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Integration tests for Calendar CalDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_calendar_name():
|
||||
"""Unique calendar name for testing."""
|
||||
return f"test_calendar_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_calendar(nc_client: NextcloudClient, test_calendar_name: str):
|
||||
"""Create a temporary calendar for testing and clean up afterward."""
|
||||
calendar_name = test_calendar_name
|
||||
|
||||
try:
|
||||
# Create a test calendar
|
||||
logger.info(f"Creating temporary calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=f"Test Calendar {calendar_name}",
|
||||
description="Temporary calendar for integration testing",
|
||||
color="#FF5722",
|
||||
)
|
||||
|
||||
if result["status_code"] not in [200, 201]:
|
||||
pytest.skip(f"Failed to create temporary calendar: {result}")
|
||||
|
||||
logger.info(f"Created temporary calendar: {calendar_name}")
|
||||
yield calendar_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up temporary calendar: {e}")
|
||||
pytest.skip(f"Calendar setup failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete the temporary calendar
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary calendar: {calendar_name}")
|
||||
await nc_client.calendar.delete_calendar(calendar_name)
|
||||
logger.info(f"Successfully deleted temporary calendar: {calendar_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting temporary calendar {calendar_name}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Create a temporary event for testing and clean up afterward."""
|
||||
event_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create a test event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": f"Test Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test event created by integration tests",
|
||||
"location": "Test Location",
|
||||
"categories": "testing",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary event in calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result.get("uid")
|
||||
|
||||
if not event_uid:
|
||||
pytest.fail("Failed to create temporary event")
|
||||
|
||||
logger.info(f"Created temporary event with UID: {event_uid}")
|
||||
yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if event_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary event: {event_uid}")
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
logger.info(f"Successfully deleted temporary event: {event_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary event {event_uid}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary event {event_uid}: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_calendars(nc_client: NextcloudClient):
|
||||
"""Test listing available calendars."""
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
|
||||
assert isinstance(calendars, list)
|
||||
|
||||
if not calendars:
|
||||
pytest.skip("No calendars available - Calendar app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(calendars)} calendars")
|
||||
|
||||
# Check structure of calendars
|
||||
for calendar in calendars:
|
||||
assert "name" in calendar
|
||||
assert "display_name" in calendar
|
||||
assert "href" in calendar
|
||||
# Optional fields
|
||||
assert "description" in calendar
|
||||
assert "color" in calendar
|
||||
|
||||
logger.info(f"Calendar: {calendar['name']} - {calendar['display_name']}")
|
||||
|
||||
|
||||
async def test_create_and_delete_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and deleting a basic event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Integration Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Test event for integration testing",
|
||||
"location": "Test Room",
|
||||
"categories": "testing,integration",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 3,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
assert "uid" in result
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with UID: {event_uid}")
|
||||
|
||||
# Verify event was created by retrieving it
|
||||
retrieved_event, etag = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["uid"] == event_uid
|
||||
assert retrieved_event["title"] == "Integration Test Event"
|
||||
assert retrieved_event["location"] == "Test Room"
|
||||
|
||||
# Delete event
|
||||
delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_all_day_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an all-day event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "All Day Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%d"),
|
||||
"all_day": True,
|
||||
"description": "Test all-day event",
|
||||
"categories": "testing",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created all-day event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "All Day Test Event"
|
||||
assert retrieved_event.get("all_day") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"All-day event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_recurring_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating a recurring event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Weekly Recurring Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test recurring event",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
||||
"reminder_minutes": 30,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created recurring event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Weekly Recurring Test"
|
||||
assert retrieved_event.get("recurring") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recurring event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test listing events within a date range."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
|
||||
# Get events for the next week
|
||||
start_datetime = datetime.now()
|
||||
end_datetime = datetime.now() + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
assert isinstance(events, list)
|
||||
logger.info(f"Found {len(events)} events in date range")
|
||||
|
||||
# Our temporary event should be in the list
|
||||
event_uids = [event.get("uid") for event in events]
|
||||
assert temporary_event["uid"] in event_uids
|
||||
|
||||
# Check event structure
|
||||
for event in events:
|
||||
assert "uid" in event
|
||||
assert "title" in event
|
||||
assert "start_datetime" in event
|
||||
|
||||
|
||||
async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test updating an existing event."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
event_uid = temporary_event["uid"]
|
||||
|
||||
# Update event data
|
||||
updated_data = {
|
||||
"title": "Updated Test Event Title",
|
||||
"description": "Updated description for test event",
|
||||
"location": "Updated Location",
|
||||
"priority": 1, # High priority
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.update_event(
|
||||
calendar_name, event_uid, updated_data
|
||||
)
|
||||
assert result["uid"] == event_uid
|
||||
|
||||
# Verify updates
|
||||
updated_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert updated_event["title"] == "Updated Test Event Title"
|
||||
assert updated_event["description"] == "Updated description for test event"
|
||||
assert updated_event["location"] == "Updated Location"
|
||||
assert updated_event["priority"] == 1
|
||||
|
||||
logger.info(f"Successfully updated event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event update test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with attendees."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Meeting with Attendees",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT16:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT17:00:00"),
|
||||
"description": "Test meeting with multiple attendees",
|
||||
"location": "Conference Room A",
|
||||
"attendees": "test1@example.com,test2@example.com",
|
||||
"reminder_minutes": 15,
|
||||
"status": "TENTATIVE",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with attendees, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Meeting with Attendees"
|
||||
assert "test1@example.com" in retrieved_event.get("attendees", "")
|
||||
assert retrieved_event["status"] == "TENTATIVE"
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with attendees test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_get_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test retrieving a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.calendar.get_event(calendar_name, fake_uid)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test deleting a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
result = await nc_client.calendar.delete_event(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_event_with_url_and_categories(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with URL and multiple categories."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Event with URL and Categories",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT10:30:00"),
|
||||
"description": "Test event with additional metadata",
|
||||
"categories": "work,meeting,important,quarterly",
|
||||
"url": "https://zoom.us/j/123456789",
|
||||
"privacy": "PRIVATE",
|
||||
"priority": 2,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with metadata, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Event with URL and Categories"
|
||||
assert "work" in retrieved_event.get("categories", "")
|
||||
assert "important" in retrieved_event.get("categories", "")
|
||||
assert retrieved_event.get("url") == "https://zoom.us/j/123456789"
|
||||
assert retrieved_event.get("privacy") == "PRIVATE"
|
||||
assert retrieved_event.get("priority") == 2
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with metadata test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""Test error handling for calendar operations."""
|
||||
|
||||
# Test with non-existent calendar
|
||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Integration tests for Contacts MCP tools."""
|
||||
|
||||
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_contacts_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Contacts workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
addressbook_name = f"mcp-test-addressbook-{uuid.uuid4().hex[:8]}"
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
contact_uid = f"mcp-contact-{unique_suffix}"
|
||||
contact_data = {
|
||||
"fn": f"MCP Contact {unique_suffix}",
|
||||
"email": f"mcp.contact.{unique_suffix}@example.com",
|
||||
"tel": "1234567890",
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. Create address book via MCP
|
||||
logger.info(f"Creating address book via MCP: {addressbook_name}")
|
||||
create_ab_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_create_addressbook",
|
||||
{"name": addressbook_name, "display_name": f"MCP Test {addressbook_name}"},
|
||||
)
|
||||
assert create_ab_result.isError is False
|
||||
|
||||
# 2. Verify address book creation
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
assert any(ab["name"] == addressbook_name for ab in addressbooks)
|
||||
|
||||
# 3. Create contact via MCP
|
||||
logger.info(f"Creating contact in {addressbook_name} via MCP")
|
||||
create_c_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_create_contact",
|
||||
{
|
||||
"addressbook": addressbook_name,
|
||||
"uid": contact_uid,
|
||||
"contact_data": contact_data,
|
||||
},
|
||||
)
|
||||
assert create_c_result.isError is False
|
||||
|
||||
# 4. Verify contact creation
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
assert any(c["vcard_id"] == contact_uid for c in contacts)
|
||||
|
||||
# 5. Delete contact via MCP
|
||||
logger.info(f"Deleting contact {contact_uid} via MCP")
|
||||
delete_c_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_delete_contact",
|
||||
{"addressbook": addressbook_name, "uid": contact_uid},
|
||||
)
|
||||
assert delete_c_result.isError is False
|
||||
|
||||
# 6. Verify contact deletion
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
assert not any(c["vcard_id"] == contact_uid for c in contacts)
|
||||
|
||||
# 7. Delete address book via MCP
|
||||
logger.info(f"Deleting address book {addressbook_name} via MCP")
|
||||
delete_ab_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_delete_addressbook", {"name": addressbook_name}
|
||||
)
|
||||
assert delete_ab_result.isError is False
|
||||
|
||||
# 8. Verify address book deletion
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
assert not any(ab["name"] == addressbook_name for ab in addressbooks)
|
||||
|
||||
finally:
|
||||
# Cleanup in case of failure
|
||||
try:
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Integration tests for Contacts CardDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_list_addressbooks(nc_client: NextcloudClient):
|
||||
"""Test listing available addressbooks."""
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
|
||||
assert isinstance(addressbooks, list)
|
||||
|
||||
if not addressbooks:
|
||||
pytest.skip("No addressbooks available - Contacts app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(addressbooks)} addressbooks")
|
||||
|
||||
# Check structure of addressbooks
|
||||
for addressbook in addressbooks:
|
||||
assert "name" in addressbook
|
||||
assert "display_name" in addressbook
|
||||
assert "getctag" in addressbook
|
||||
|
||||
logger.info(
|
||||
f"Addressbook: {addressbook['name']} - {addressbook['display_name']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_create_and_delete_addressbook(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str
|
||||
):
|
||||
"""Test creating and deleting a basic addressbook."""
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
addressbook_names = [ab["name"] for ab in addressbooks]
|
||||
assert temporary_addressbook in addressbook_names
|
||||
|
||||
|
||||
async def test_list_contacts(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str, temporary_contact: str
|
||||
):
|
||||
"""Test listing contacts in an addressbook."""
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=temporary_addressbook)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert temporary_contact in contact_uids
|
||||
|
||||
|
||||
async def test_full_contact_workflow(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str
|
||||
):
|
||||
"""Test the full workflow of creating, retrieving, and deleting a contact."""
|
||||
addressbook_name = temporary_addressbook
|
||||
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
|
||||
contact_data = {
|
||||
"fn": "Jane Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
"tel": "9876543210",
|
||||
}
|
||||
|
||||
# Create contact
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=contact_data,
|
||||
)
|
||||
|
||||
# Verify contact was created by listing
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert contact_uid in contact_uids
|
||||
|
||||
# Delete contact
|
||||
await nc_client.contacts.delete_contact(
|
||||
addressbook=addressbook_name, uid=contact_uid
|
||||
)
|
||||
|
||||
# Verify contact was deleted
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert contact_uid not in contact_uids
|
||||
@@ -1,10 +1,11 @@
|
||||
import pytest
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
from PIL import Image, ImageDraw
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError # Import if needed for specific error checks
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Test error propagation in the MCP server for various error scenarios."""
|
||||
|
||||
import logging
|
||||
from mcp import ClientSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_missing_note_resource_error(nc_mcp_client: ClientSession):
|
||||
"""Test that accessing a non-existent note resource returns proper error."""
|
||||
# Try to get a non-existent note via resource - should raise McpError with improved message
|
||||
with pytest.raises(McpError, match=r"Note 999999 not found"):
|
||||
await nc_mcp_client.read_resource("nc://Notes/999999")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
|
||||
"""Test that deleting a non-existent note returns proper error."""
|
||||
# Try to delete a non-existent note
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": 999999}
|
||||
)
|
||||
|
||||
logger.info(f"Delete missing note response: {response}")
|
||||
|
||||
# Should return structured error response with improved message
|
||||
assert response is not None
|
||||
assert (
|
||||
response.isError is False
|
||||
) # Tools now return structured responses, not MCP errors
|
||||
|
||||
# Check structured content for error
|
||||
assert "success" in response.structuredContent["result"]
|
||||
assert response.structuredContent["result"]["success"] is False
|
||||
assert "Note 999999 not found" in response.structuredContent["result"]["error"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_search_with_empty_query(nc_mcp_client: ClientSession):
|
||||
"""Test search behavior with empty query."""
|
||||
# Search with empty query
|
||||
response = await nc_mcp_client.call_tool("nc_notes_search_notes", {"query": ""})
|
||||
|
||||
logger.info(f"Empty search query response: {response}")
|
||||
|
||||
# Should return successful response with empty or valid results
|
||||
assert response is not None
|
||||
assert response.isError is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession):
|
||||
"""Test calling a tool with missing required parameters."""
|
||||
# Try to create note with missing parameters
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{"title": "Test"}, # Missing content and category
|
||||
)
|
||||
logger.info(f"Missing params response: {response}")
|
||||
|
||||
# Should return error response for missing required parameters
|
||||
assert response is not None
|
||||
assert response.isError is True
|
||||
assert (
|
||||
"required" in response.content[0].text.lower()
|
||||
or "missing" in response.content[0].text.lower()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_client):
|
||||
"""Test updating a note with invalid ETag."""
|
||||
# First create a note
|
||||
note_data = await nc_client.notes.create_note(
|
||||
title="Test Note for ETag", content="Test content", category=""
|
||||
)
|
||||
note_id = note_data["id"]
|
||||
|
||||
try:
|
||||
# Try to update with invalid ETag
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"etag": "invalid-etag",
|
||||
"title": "Updated Title",
|
||||
"content": None,
|
||||
"category": None,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Invalid ETag response: {response}")
|
||||
|
||||
# Should return structured error response with improved message
|
||||
assert response is not None
|
||||
assert response.isError is False # Tools now return structured responses
|
||||
assert "success" in response.structuredContent["result"]
|
||||
assert response.structuredContent["result"]["success"] is False
|
||||
assert (
|
||||
"modified by someone else" in response.structuredContent["result"]["error"]
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession):
|
||||
"""Test calendar operations with non-existent calendar."""
|
||||
# Try to create event in non-existent calendar
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event",
|
||||
{
|
||||
"calendar_name": "non-existent-calendar",
|
||||
"title": "Test Event",
|
||||
"start_datetime": "2025-01-15T14:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Non-existent calendar response: {response}")
|
||||
|
||||
# Should return structured error response
|
||||
assert response is not None
|
||||
# Note: Some modules may not have improved error handling yet
|
||||
# Check if we have structured content with success=false or isError=true
|
||||
if (
|
||||
hasattr(response, "structuredContent")
|
||||
and response.structuredContent
|
||||
and "result" in response.structuredContent
|
||||
):
|
||||
assert response.structuredContent["result"]["success"] is False
|
||||
else:
|
||||
assert response.isError is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession):
|
||||
"""Test WebDAV operations with non-existent file."""
|
||||
# Try to read a non-existent file
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", {"path": "non-existent-file.txt"}
|
||||
)
|
||||
|
||||
logger.info(f"Missing file response: {response}")
|
||||
|
||||
# Should return structured error response
|
||||
assert response is not None
|
||||
# Note: Some modules may not have improved error handling yet
|
||||
# Check if we have structured content with success=false or isError=true
|
||||
if (
|
||||
hasattr(response, "structuredContent")
|
||||
and response.structuredContent
|
||||
and "result" in response.structuredContent
|
||||
):
|
||||
assert response.structuredContent["result"]["success"] is False
|
||||
else:
|
||||
assert response.isError is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_tables_missing_table_error(nc_mcp_client: ClientSession):
|
||||
"""Test Tables operations with non-existent table."""
|
||||
# Try to get schema of non-existent table
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_tables_get_schema", {"table_id": 999999}
|
||||
)
|
||||
|
||||
logger.info(f"Missing table response: {response}")
|
||||
|
||||
# Should return structured error response
|
||||
assert response is not None
|
||||
# Note: Some modules may not have improved error handling yet
|
||||
# Check if we have structured content with success=false or isError=true
|
||||
if (
|
||||
hasattr(response, "structuredContent")
|
||||
and response.structuredContent
|
||||
and "result" in response.structuredContent
|
||||
):
|
||||
assert response.structuredContent["result"]["success"] is False
|
||||
else:
|
||||
assert response.isError is True
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Integration tests for CalDAV and CardDAV field preservation.
|
||||
|
||||
This test module demonstrates data loss issues when non-supported fields
|
||||
are present in calendar events and contacts during round-trip operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
"""Test that demonstrates loss of non-supported iCal fields during round-trip operations."""
|
||||
calendar_name = "personal"
|
||||
|
||||
# Create an event with standard fields
|
||||
event_data = {
|
||||
"title": "Test Event with Custom Fields",
|
||||
"description": "Event to test custom field preservation",
|
||||
"start_datetime": (datetime.now() + timedelta(days=1)).isoformat(),
|
||||
"end_datetime": (datetime.now() + timedelta(days=1, hours=1)).isoformat(),
|
||||
"location": "Test Location",
|
||||
}
|
||||
|
||||
# Create the event
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Now manually inject a custom iCal property by creating a new version with raw iCal
|
||||
# This simulates what would happen if the event was created by another CalDAV client
|
||||
# with extended properties
|
||||
custom_ical = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{event_uid}
|
||||
DTSTART:{(datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTEND:{(datetime.now() + timedelta(days=1, hours=1)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
SUMMARY:Test Event with Custom Fields
|
||||
DESCRIPTION:Event to test custom field preservation
|
||||
LOCATION:Test Location
|
||||
X-CUSTOM-FIELD:This is a custom field that should be preserved
|
||||
X-VENDOR-SPECIFIC:Vendor specific data
|
||||
CATEGORIES:work,testing
|
||||
STATUS:CONFIRMED
|
||||
PRIORITY:5
|
||||
CLASS:PUBLIC
|
||||
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTSTAMP:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Direct CalDAV PUT to inject the custom iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=custom_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
|
||||
logger.info(f"Injected custom iCal properties into event {event_uid}")
|
||||
|
||||
# Retrieve the event to confirm custom fields are present in raw iCal
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_before = response.text
|
||||
|
||||
logger.info("Raw iCal before update:")
|
||||
logger.info(raw_ical_before)
|
||||
|
||||
# Verify custom fields exist in raw iCal
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_before
|
||||
)
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_before
|
||||
|
||||
# Now update the event through the MCP client (simulating normal usage)
|
||||
update_data = {
|
||||
"title": "Updated Test Event with Custom Fields",
|
||||
"description": "Updated description - custom fields should be preserved",
|
||||
}
|
||||
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
logger.info(f"Updated event {event_uid} through MCP client")
|
||||
|
||||
# Retrieve the event again to see if custom fields survived
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_after = response_after.text
|
||||
|
||||
logger.info("Raw iCal after update:")
|
||||
logger.info(raw_ical_after)
|
||||
|
||||
# THIS IS THE TEST THAT SHOULD FAIL - custom fields should be preserved but won't be
|
||||
try:
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_after
|
||||
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
|
||||
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
|
||||
)
|
||||
logger.info(
|
||||
"✓ Custom fields were preserved (unexpected - this should fail with current implementation)"
|
||||
)
|
||||
except AssertionError as e:
|
||||
logger.error(f"✗ Custom fields were lost during round-trip update: {e}")
|
||||
# Re-raise to show the test failure
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_contact_extended_fields_preservation(nc_client):
|
||||
"""Test that demonstrates loss of extended vCard fields during round-trip operations."""
|
||||
addressbook_name = f"test_preservation_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create a temporary addressbook
|
||||
await nc_client.contacts.create_addressbook(
|
||||
name=addressbook_name, display_name="Test Preservation Addressbook"
|
||||
)
|
||||
|
||||
try:
|
||||
contact_uid = str(uuid.uuid4())
|
||||
|
||||
# Create a contact with minimal data first
|
||||
basic_contact_data = {
|
||||
"fn": "John Extended Doe",
|
||||
"email": "john.extended@example.com",
|
||||
}
|
||||
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=basic_contact_data,
|
||||
)
|
||||
|
||||
logger.info(f"Created basic contact {contact_uid}")
|
||||
|
||||
# Now inject a rich vCard with extended fields directly via CardDAV
|
||||
extended_vcard = f"""BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:{contact_uid}
|
||||
FN:John Extended Doe
|
||||
N:Doe;John;Extended;;
|
||||
NICKNAME:Johnny,JD
|
||||
EMAIL;TYPE=work:john.work@company.com
|
||||
EMAIL;TYPE=home:john.extended@example.com
|
||||
TEL;TYPE=cell:+1-555-123-4567
|
||||
TEL;TYPE=work:+1-555-987-6543
|
||||
ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA
|
||||
ADR;TYPE=work:;;456 Work Ave;Worktown;ST;54321;USA
|
||||
ORG:Example Corporation
|
||||
TITLE:Senior Developer
|
||||
URL;TYPE=work:https://company.com/john
|
||||
URL;TYPE=personal:https://johndoe.dev
|
||||
BDAY:1985-06-15
|
||||
NOTE:This is a note with important information that should be preserved.
|
||||
CATEGORIES:colleagues,developers,friends
|
||||
X-CUSTOM-FIELD:This should be preserved
|
||||
X-SKYPE:john.doe.skype
|
||||
X-LINKEDIN:https://linkedin.com/in/johndoe
|
||||
REV:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VCARD"""
|
||||
|
||||
# Direct CardDAV PUT to inject the extended vCard
|
||||
contact_path = f"/remote.php/dav/addressbooks/users/{nc_client.contacts.username}/{addressbook_name}/{contact_uid}.vcf"
|
||||
await nc_client.contacts._make_request(
|
||||
"PUT",
|
||||
contact_path,
|
||||
content=extended_vcard,
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
)
|
||||
|
||||
logger.info(f"Injected extended vCard for contact {contact_uid}")
|
||||
|
||||
# Retrieve the contact to confirm extended fields are present in raw vCard
|
||||
response = await nc_client.contacts._make_request("GET", contact_path)
|
||||
raw_vcard_before = response.text
|
||||
|
||||
logger.info("Raw vCard before any operations:")
|
||||
logger.info(raw_vcard_before)
|
||||
|
||||
# Verify extended fields exist in raw vCard
|
||||
assert "TEL;TYPE=cell:+1-555-123-4567" in raw_vcard_before
|
||||
assert "ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA" in raw_vcard_before
|
||||
assert "ORG:Example Corporation" in raw_vcard_before
|
||||
assert "TITLE:Senior Developer" in raw_vcard_before
|
||||
assert "X-CUSTOM-FIELD:This should be preserved" in raw_vcard_before
|
||||
assert "X-LINKEDIN:https://linkedin.com/in/johndoe" in raw_vcard_before
|
||||
assert "NOTE:This is a note with important information" in raw_vcard_before
|
||||
|
||||
# List contacts through the MCP client (this will parse and return limited fields)
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
our_contact = next((c for c in contacts if c["vcard_id"] == contact_uid), None)
|
||||
|
||||
assert our_contact is not None
|
||||
logger.info("Contact as parsed by MCP client:")
|
||||
logger.info(our_contact)
|
||||
|
||||
# Check what fields are accessible through the parsed contact
|
||||
parsed_contact = our_contact["contact"]
|
||||
|
||||
# These should be available (basic fields that are parsed)
|
||||
assert parsed_contact["fullname"] == "John Extended Doe"
|
||||
assert parsed_contact["email"] is not None # Some email should be present
|
||||
|
||||
# The raw vCard should still be available in addressdata
|
||||
raw_addressdata = our_contact["addressdata"]
|
||||
assert "X-CUSTOM-FIELD:This should be preserved" in raw_addressdata
|
||||
assert "ORG:Example Corporation" in raw_addressdata
|
||||
|
||||
# The key test: Can we update this contact without losing extended field data?
|
||||
logger.info("Testing contact update preservation...")
|
||||
|
||||
# Update the contact through the MCP client with a simple change
|
||||
try:
|
||||
await nc_client.contacts.update_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data={"email": "john.updated@example.com"},
|
||||
)
|
||||
logger.info("✓ Contact updated successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Failed to update contact: {e}")
|
||||
raise
|
||||
|
||||
# Retrieve the contact again to see if extended fields survived
|
||||
contacts_after = await nc_client.contacts.list_contacts(
|
||||
addressbook=addressbook_name
|
||||
)
|
||||
updated_contact = next(
|
||||
(c for c in contacts_after if c["vcard_id"] == contact_uid), None
|
||||
)
|
||||
|
||||
assert updated_contact is not None, "Contact not found after update"
|
||||
updated_addressdata = updated_contact["addressdata"]
|
||||
|
||||
logger.info("Raw vCard after contact update:")
|
||||
logger.info(updated_addressdata)
|
||||
|
||||
# THIS IS THE CRITICAL TEST - extended fields should be preserved during updates
|
||||
extended_field_checks = [
|
||||
("ORG:Example Corporation", "organization field"),
|
||||
("TITLE:Senior Developer", "title field"),
|
||||
("TEL;TYPE=cell:+1-555-123-4567", "cell phone"),
|
||||
("TEL;TYPE=work:+1-555-987-6543", "work phone"),
|
||||
("ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA", "home address"),
|
||||
("ADR;TYPE=work:;;456 Work Ave;Worktown;ST;54321;USA", "work address"),
|
||||
("URL;TYPE=work;VALUE=URI:https://company.com/john", "work URL"),
|
||||
("NOTE:This is a note with important information", "note field"),
|
||||
("CATEGORIES:colleagues,developers,friends", "categories"),
|
||||
("X-CUSTOM-FIELD:This should be preserved", "custom field"),
|
||||
("X-LINKEDIN:https://linkedin.com/in/johndoe", "LinkedIn custom field"),
|
||||
("john.updated@example.com", "updated email"),
|
||||
]
|
||||
|
||||
all_preserved = True
|
||||
for field_pattern, field_name in extended_field_checks:
|
||||
if field_pattern in updated_addressdata:
|
||||
logger.info(f"✓ {field_name} preserved")
|
||||
else:
|
||||
logger.error(f"✗ {field_name} was lost during update")
|
||||
all_preserved = False
|
||||
|
||||
# The test should PASS - field preservation should work
|
||||
assert all_preserved, (
|
||||
"Contact update lost extended field data - this indicates the preservation mechanism failed"
|
||||
)
|
||||
|
||||
logger.info("🎉 SUCCESS: All extended fields preserved during contact update!")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(
|
||||
f"Failed to cleanup addressbook {addressbook_name}: {cleanup_error}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
"""Demonstrates specific data loss scenarios in calendar events."""
|
||||
calendar_name = "personal"
|
||||
|
||||
event_data = {
|
||||
"title": "Roundtrip Test Event",
|
||||
"description": "Testing data preservation",
|
||||
"start_datetime": (datetime.now() + timedelta(days=2)).isoformat(),
|
||||
"end_datetime": (datetime.now() + timedelta(days=2, hours=1)).isoformat(),
|
||||
}
|
||||
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Inject additional iCal properties that are valid but not supported by our parser
|
||||
extended_ical = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Extended Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{event_uid}
|
||||
DTSTART:{(datetime.now() + timedelta(days=2)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTEND:{(datetime.now() + timedelta(days=2, hours=1)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
SUMMARY:Roundtrip Test Event
|
||||
DESCRIPTION:Testing data preservation
|
||||
STATUS:CONFIRMED
|
||||
PRIORITY:5
|
||||
CLASS:PUBLIC
|
||||
SEQUENCE:1
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-CUSTOM-MEETING-ID:12345-67890
|
||||
X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890
|
||||
ORGANIZER;CN=Test Organizer:mailto:organizer@example.com
|
||||
COMMENT:This is a comment that should be preserved
|
||||
LOCATION:Conference Room A
|
||||
GEO:40.7128;-74.0060
|
||||
TRANSP:OPAQUE
|
||||
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTSTAMP:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Inject the extended iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=extended_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
|
||||
# Verify extended properties are present
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
original_ical = response.text
|
||||
|
||||
# Confirm extended properties exist
|
||||
extended_properties = [
|
||||
"SEQUENCE:1",
|
||||
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
|
||||
"X-CUSTOM-MEETING-ID:12345-67890",
|
||||
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
|
||||
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com",
|
||||
"COMMENT:This is a comment that should be preserved",
|
||||
"GEO:40.7128;-74.0060",
|
||||
"TRANSP:OPAQUE",
|
||||
]
|
||||
|
||||
# More flexible patterns for properties that might be reformatted
|
||||
flexible_patterns = {
|
||||
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com": [
|
||||
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com",
|
||||
'ORGANIZER;CN="Test Organizer":mailto:organizer@example.com',
|
||||
],
|
||||
"GEO:40.7128;-74.0060": [
|
||||
"GEO:40.7128;-74.0060",
|
||||
"GEO:40.7128;-74.006", # May lose trailing zero
|
||||
],
|
||||
}
|
||||
|
||||
for prop in extended_properties:
|
||||
assert prop in original_ical, (
|
||||
f"Extended property {prop} not found in original iCal"
|
||||
)
|
||||
|
||||
logger.info("✓ All extended properties confirmed in original iCal")
|
||||
|
||||
# Now perform a simple update through MCP
|
||||
update_data = {"location": "Conference Room B"} # Simple location change
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
|
||||
# Check what survived the round-trip
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
updated_ical = response_after.text
|
||||
|
||||
logger.info("Checking which properties survived the update...")
|
||||
|
||||
# Check which extended properties survived
|
||||
survived = []
|
||||
lost = []
|
||||
|
||||
for prop in extended_properties:
|
||||
# Check if this property has flexible patterns
|
||||
if prop in flexible_patterns:
|
||||
# Check if any of the flexible patterns match
|
||||
found = any(
|
||||
pattern in updated_ical for pattern in flexible_patterns[prop]
|
||||
)
|
||||
if found:
|
||||
survived.append(prop)
|
||||
else:
|
||||
lost.append(prop)
|
||||
else:
|
||||
# Standard exact match
|
||||
if prop in updated_ical:
|
||||
survived.append(prop)
|
||||
else:
|
||||
lost.append(prop)
|
||||
|
||||
logger.info(f"Properties that SURVIVED: {survived}")
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
|
||||
# This test should fail - we expect data loss
|
||||
assert len(lost) == 0, (
|
||||
f"Round-trip update lost {len(lost)} extended properties: {lost}"
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
|
||||
@@ -0,0 +1,694 @@
|
||||
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_connectivity(nc_mcp_client: ClientSession):
|
||||
"""Test basic MCP server connectivity and list available tools/resources."""
|
||||
|
||||
# List available tools
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
logger.info("Available MCP tools:")
|
||||
tool_names = []
|
||||
for tool in tools.tools:
|
||||
logger.info(f" - {tool.name}: {tool.description}")
|
||||
tool_names.append(tool.name)
|
||||
|
||||
# Verify expected tools are present
|
||||
expected_tools = [
|
||||
"nc_notes_create_note",
|
||||
"nc_notes_update_note",
|
||||
"nc_notes_append_content",
|
||||
"nc_notes_search_notes",
|
||||
"nc_notes_delete_note",
|
||||
"nc_tables_list_tables",
|
||||
"nc_tables_get_schema",
|
||||
"nc_tables_read_table",
|
||||
"nc_tables_insert_row",
|
||||
"nc_tables_update_row",
|
||||
"nc_tables_delete_row",
|
||||
"nc_webdav_list_directory",
|
||||
"nc_webdav_read_file",
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
"nc_webdav_delete_resource",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_list_events",
|
||||
"nc_calendar_get_event",
|
||||
"nc_calendar_update_event",
|
||||
"nc_calendar_delete_event",
|
||||
"nc_calendar_create_meeting",
|
||||
"nc_calendar_get_upcoming_events",
|
||||
"nc_calendar_find_availability",
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
]
|
||||
|
||||
for expected_tool in expected_tools:
|
||||
assert expected_tool in tool_names, (
|
||||
f"Expected tool '{expected_tool}' not found in available tools"
|
||||
)
|
||||
|
||||
# List available resource templates
|
||||
templates = await nc_mcp_client.list_resource_templates()
|
||||
logger.info("\nAvailable resource templates:")
|
||||
template_uris = []
|
||||
for template in templates.resourceTemplates:
|
||||
logger.info(f" - {template.uriTemplate}")
|
||||
template_uris.append(template.uriTemplate)
|
||||
|
||||
# Verify expected resource templates
|
||||
expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"]
|
||||
|
||||
for expected_template in expected_templates:
|
||||
assert expected_template in template_uris, (
|
||||
f"Expected template '{expected_template}' not found"
|
||||
)
|
||||
|
||||
# List available resources
|
||||
resources = await nc_mcp_client.list_resources()
|
||||
logger.info("\nAvailable resources:")
|
||||
resource_uris = []
|
||||
for resource in resources.resources:
|
||||
logger.info(f" - {resource.uri}: {resource.name}")
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
expected_resources = ["nc://capabilities", "notes://settings"]
|
||||
|
||||
for expected_resource in expected_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
f"Expected resource '{expected_resource}' not found"
|
||||
)
|
||||
|
||||
# List available prompts
|
||||
prompts = await nc_mcp_client.list_prompts()
|
||||
logger.info("\nAvailable prompts:")
|
||||
for prompt in prompts.prompts:
|
||||
logger.info(f" - {prompt.name}")
|
||||
|
||||
|
||||
async def test_mcp_notes_crud_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Notes CRUD workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_title = f"MCP Test Note {unique_suffix}"
|
||||
test_content = f"This is test content for note {unique_suffix}"
|
||||
test_category = "MCPTesting"
|
||||
|
||||
created_note = None
|
||||
|
||||
try:
|
||||
# 1. Create note via MCP
|
||||
logger.info(f"Creating note via MCP: {test_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{"title": test_title, "content": test_content, "category": test_category},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP note creation failed: {create_result.content}"
|
||||
)
|
||||
created_note = create_result.content[0].text
|
||||
note_data = json.loads(created_note) # Parse the returned JSON
|
||||
note_id = note_data["id"]
|
||||
|
||||
logger.info(f"Note created via MCP with ID: {note_id}")
|
||||
|
||||
# 2. Verify creation via direct NextcloudClient
|
||||
direct_note = await nc_client.notes.get_note(note_id)
|
||||
assert direct_note["title"] == test_title, (
|
||||
f"Title mismatch: {direct_note['title']} != {test_title}"
|
||||
)
|
||||
assert direct_note["content"] == test_content, "Content mismatch"
|
||||
assert direct_note["category"] == test_category, "Category mismatch"
|
||||
|
||||
# 3. Read note via MCP
|
||||
logger.info(f"Reading note via MCP: {note_id}")
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}")
|
||||
assert len(read_result.contents) == 1, "Expected exactly one content item"
|
||||
read_note_data = json.loads(read_result.contents[0].text)
|
||||
|
||||
assert read_note_data["title"] == test_title
|
||||
assert read_note_data["content"] == test_content
|
||||
assert read_note_data["category"] == test_category
|
||||
|
||||
# 4. Update note via MCP
|
||||
updated_title = f"Updated {test_title}"
|
||||
updated_content = f"Updated content: {test_content}"
|
||||
etag = read_note_data["etag"]
|
||||
|
||||
logger.info(f"Updating note via MCP: {note_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"etag": etag,
|
||||
"title": updated_title,
|
||||
"content": updated_content,
|
||||
"category": test_category,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP note update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 5. Verify update via direct NextcloudClient
|
||||
updated_direct_note = await nc_client.notes.get_note(note_id)
|
||||
assert updated_direct_note["title"] == updated_title
|
||||
assert updated_direct_note["content"] == updated_content
|
||||
|
||||
# 6. Append content via MCP
|
||||
append_content = "\n\nThis is appended content via MCP."
|
||||
logger.info(f"Appending content to note via MCP: {note_id}")
|
||||
append_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_append_content", {"note_id": note_id, "content": append_content}
|
||||
)
|
||||
|
||||
assert append_result.isError is False, (
|
||||
f"MCP note append failed: {append_result.content}"
|
||||
)
|
||||
|
||||
# 7. Verify append via direct NextcloudClient
|
||||
appended_direct_note = await nc_client.notes.get_note(note_id)
|
||||
assert append_content in appended_direct_note["content"]
|
||||
|
||||
# 8. Search for note via MCP
|
||||
logger.info(f"Searching for note via MCP with query: {unique_suffix}")
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", {"query": unique_suffix}
|
||||
)
|
||||
|
||||
assert search_result.isError is False, (
|
||||
f"MCP note search failed: {search_result.content}"
|
||||
)
|
||||
search_notes_text = search_result.content[0].text
|
||||
logger.info(f"Search result text: {search_notes_text}")
|
||||
search_response = json.loads(search_notes_text)
|
||||
|
||||
# Expect structured response with Pydantic format
|
||||
assert isinstance(search_response, dict), (
|
||||
f"Expected search response to be a dict with structured format, got: {type(search_response)}"
|
||||
)
|
||||
assert "results" in search_response, (
|
||||
f"Expected 'results' field in search response, got keys: {list(search_response.keys())}"
|
||||
)
|
||||
assert "success" in search_response and search_response["success"], (
|
||||
f"Expected successful search response, got: {search_response}"
|
||||
)
|
||||
|
||||
search_notes = search_response["results"]
|
||||
assert isinstance(search_notes, list), (
|
||||
f"Expected results to be a list, got: {type(search_notes)}"
|
||||
)
|
||||
|
||||
# Find our note in search results
|
||||
found_note = None
|
||||
for note in search_notes:
|
||||
if isinstance(note, dict) and note.get("id") == note_id:
|
||||
found_note = note
|
||||
break
|
||||
|
||||
assert found_note is not None, (
|
||||
f"Created note not found in search results. Search returned: {search_response}"
|
||||
)
|
||||
assert found_note["title"] == updated_title
|
||||
|
||||
# 9. Delete note via MCP
|
||||
logger.info(f"Deleting note via MCP: {note_id}")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, (
|
||||
f"MCP note deletion failed: {delete_result.content}"
|
||||
)
|
||||
|
||||
# 10. Verify deletion via direct NextcloudClient
|
||||
try:
|
||||
await nc_client.notes.get_note(note_id)
|
||||
pytest.fail("Note should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - note should be deleted
|
||||
logger.info(f"Successfully verified note {note_id} was deleted")
|
||||
created_note = None # Mark as cleaned up
|
||||
|
||||
finally:
|
||||
# Cleanup in case of test failure
|
||||
if created_note is not None:
|
||||
try:
|
||||
note_data = json.loads(created_note)
|
||||
await nc_client.notes.delete_note(note_data["id"])
|
||||
logger.info(f"Cleaned up note {note_data['id']} after test failure")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup note: {e}")
|
||||
|
||||
|
||||
async def test_mcp_webdav_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test WebDAV file operations via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_dir = f"mcp_test_dir_{unique_suffix}"
|
||||
test_file = f"mcp_test_file_{unique_suffix}.txt"
|
||||
test_file_path = f"{test_dir}/{test_file}"
|
||||
test_content = f"This is test content for MCP WebDAV testing {unique_suffix}"
|
||||
|
||||
try:
|
||||
# 1. Create directory via MCP
|
||||
logger.info(f"Creating directory via MCP: {test_dir}")
|
||||
create_dir_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_create_directory", {"path": test_dir}
|
||||
)
|
||||
|
||||
assert create_dir_result.isError is False, (
|
||||
f"MCP directory creation failed: {create_dir_result.content}"
|
||||
)
|
||||
|
||||
# 2. Verify directory creation via direct WebDAV
|
||||
dir_listing = await nc_client.webdav.list_directory("")
|
||||
dir_names = [item["name"] for item in dir_listing if item["is_directory"]]
|
||||
assert test_dir in dir_names, f"Directory {test_dir} not found in root listing"
|
||||
|
||||
# 3. Write file via MCP
|
||||
logger.info(f"Writing file via MCP: {test_file_path}")
|
||||
write_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
{
|
||||
"path": test_file_path,
|
||||
"content": test_content,
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
)
|
||||
|
||||
assert write_result.isError is False, (
|
||||
f"MCP file write failed: {write_result.content}"
|
||||
)
|
||||
|
||||
# 4. Verify file creation via direct WebDAV
|
||||
file_listing = await nc_client.webdav.list_directory(test_dir)
|
||||
file_names = [item["name"] for item in file_listing if not item["is_directory"]]
|
||||
assert test_file in file_names, (
|
||||
f"File {test_file} not found in directory listing"
|
||||
)
|
||||
|
||||
# 5. Read file via MCP
|
||||
logger.info(f"Reading file via MCP: {test_file_path}")
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", {"path": test_file_path}
|
||||
)
|
||||
|
||||
assert read_result.isError is False, (
|
||||
f"MCP file read failed: {read_result.content}"
|
||||
)
|
||||
read_data = json.loads(read_result.content[0].text)
|
||||
|
||||
assert read_data["content"] == test_content, "File content mismatch"
|
||||
assert read_data["path"] == test_file_path
|
||||
assert "text/plain" in read_data["content_type"]
|
||||
|
||||
# 6. Verify file content via direct WebDAV
|
||||
direct_content, direct_content_type = await nc_client.webdav.read_file(
|
||||
test_file_path
|
||||
)
|
||||
assert direct_content.decode("utf-8") == test_content
|
||||
|
||||
# 7. List directory via MCP
|
||||
logger.info(f"Listing directory via MCP: {test_dir}")
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory", {"path": test_dir}
|
||||
)
|
||||
|
||||
assert list_result.isError is False, (
|
||||
f"MCP directory listing failed: {list_result.content}"
|
||||
)
|
||||
listing_text = list_result.content[0].text
|
||||
logger.info(f"Directory listing response: {listing_text}")
|
||||
listing_data = json.loads(listing_text)
|
||||
|
||||
# Ensure listing_data is a list
|
||||
if not isinstance(listing_data, list):
|
||||
logger.warning(
|
||||
f"Expected directory listing to be a list, got: {type(listing_data)}"
|
||||
)
|
||||
listing_data = [listing_data] if listing_data else []
|
||||
|
||||
# Find our file in the listing
|
||||
found_file = None
|
||||
for item in listing_data:
|
||||
if isinstance(item, dict) and item.get("name") == test_file:
|
||||
found_file = item
|
||||
break
|
||||
|
||||
assert found_file is not None, (
|
||||
f"File {test_file} not found in MCP directory listing"
|
||||
)
|
||||
assert found_file["is_directory"] is False
|
||||
assert found_file["size"] == len(test_content.encode("utf-8"))
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
logger.info(f"Cleaning up test file: {test_file_path}")
|
||||
await nc_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": test_file_path}
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up test directory: {test_dir}")
|
||||
await nc_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": test_dir}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup WebDAV resources: {e}")
|
||||
|
||||
|
||||
async def test_mcp_resources_access(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test accessing MCP resources and compare with direct API calls."""
|
||||
|
||||
# 1. Test capabilities resource
|
||||
logger.info("Testing capabilities resource via MCP")
|
||||
caps_result = await nc_mcp_client.read_resource("nc://capabilities")
|
||||
assert len(caps_result.contents) == 1
|
||||
mcp_capabilities = json.loads(caps_result.contents[0].text)
|
||||
|
||||
# Compare with direct API call
|
||||
direct_capabilities = await nc_client.capabilities()
|
||||
|
||||
# Basic validation - both should have similar structure
|
||||
# Both return full OCS response structure
|
||||
assert "ocs" in mcp_capabilities
|
||||
assert "data" in mcp_capabilities["ocs"]
|
||||
assert "version" in mcp_capabilities["ocs"]["data"]
|
||||
assert "ocs" in direct_capabilities
|
||||
assert "data" in direct_capabilities["ocs"]
|
||||
assert "version" in direct_capabilities["ocs"]["data"]
|
||||
|
||||
# 2. Test notes settings resource
|
||||
logger.info("Testing notes settings resource via MCP")
|
||||
settings_result = await nc_mcp_client.read_resource("notes://settings")
|
||||
assert len(settings_result.contents) == 1
|
||||
mcp_settings = json.loads(settings_result.contents[0].text)
|
||||
|
||||
# Compare with direct API call
|
||||
direct_settings = await nc_client.notes.get_settings()
|
||||
|
||||
# Both should have settings data
|
||||
assert isinstance(mcp_settings, dict)
|
||||
assert isinstance(direct_settings, dict)
|
||||
|
||||
logger.info("Successfully verified MCP resources match direct API calls")
|
||||
|
||||
|
||||
async def test_mcp_calendar_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Calendar workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_event_title = f"MCP Test Event {unique_suffix}"
|
||||
test_location = f"MCP Test Location {unique_suffix}"
|
||||
|
||||
created_event = None
|
||||
calendar_name = None
|
||||
|
||||
try:
|
||||
# 1. List calendars via MCP
|
||||
logger.info("Listing calendars via MCP")
|
||||
calendars_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_calendars", {}
|
||||
)
|
||||
|
||||
assert calendars_result.isError is False, (
|
||||
f"MCP calendar listing failed: {calendars_result.content}"
|
||||
)
|
||||
|
||||
calendars_response = json.loads(calendars_result.content[0].text)
|
||||
|
||||
# Debug output to understand the structure
|
||||
logger.info(f"calendars_response type: {type(calendars_response)}")
|
||||
logger.info(f"calendars_response content: {calendars_response}")
|
||||
|
||||
# Expect structured response with Pydantic format
|
||||
assert isinstance(calendars_response, dict), (
|
||||
f"Expected calendar response to be a dict with structured format, got: {type(calendars_response)}"
|
||||
)
|
||||
assert "calendars" in calendars_response, (
|
||||
f"Expected 'calendars' field in response, got keys: {list(calendars_response.keys())}"
|
||||
)
|
||||
assert "success" in calendars_response and calendars_response["success"], (
|
||||
f"Expected successful calendar response, got: {calendars_response}"
|
||||
)
|
||||
|
||||
calendars_list = calendars_response["calendars"]
|
||||
assert isinstance(calendars_list, list), (
|
||||
f"Expected calendars to be a list, got: {type(calendars_list)}"
|
||||
)
|
||||
|
||||
if not calendars_list:
|
||||
pytest.skip("No calendars available for testing")
|
||||
|
||||
# Use the first available calendar
|
||||
calendar_name = calendars_list[0]["name"]
|
||||
logger.info(f"Using calendar: {calendar_name}")
|
||||
|
||||
# 2. Create event via MCP
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
start_datetime = tomorrow.strftime("%Y-%m-%dT14:00:00")
|
||||
end_datetime = tomorrow.strftime("%Y-%m-%dT15:00:00")
|
||||
|
||||
event_data = {
|
||||
"calendar_name": calendar_name,
|
||||
"title": test_event_title,
|
||||
"start_datetime": start_datetime,
|
||||
"end_datetime": end_datetime,
|
||||
"description": f"Test event created via MCP {unique_suffix}",
|
||||
"location": test_location,
|
||||
"categories": "testing,mcp",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
logger.info(f"Creating event via MCP: {test_event_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event", event_data
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP event creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
created_event_data = json.loads(create_result.content[0].text)
|
||||
event_uid = created_event_data["uid"]
|
||||
created_event = {"uid": event_uid, "calendar_name": calendar_name}
|
||||
|
||||
logger.info(f"Event created via MCP with UID: {event_uid}")
|
||||
|
||||
# 3. Verify creation via direct NextcloudClient
|
||||
direct_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert direct_event["title"] == test_event_title
|
||||
assert direct_event["location"] == test_location
|
||||
assert "testing" in direct_event.get("categories", "")
|
||||
|
||||
# 4. Get event via MCP
|
||||
logger.info(f"Getting event via MCP: {event_uid}")
|
||||
get_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_get_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
)
|
||||
|
||||
assert get_result.isError is False, (
|
||||
f"MCP event get failed: {get_result.content}"
|
||||
)
|
||||
|
||||
get_event_data = json.loads(get_result.content[0].text)
|
||||
assert get_event_data["title"] == test_event_title
|
||||
assert get_event_data["location"] == test_location
|
||||
|
||||
# 5. **TEST nc_calendar_list_events - This is the main tool we're testing**
|
||||
logger.info("Testing nc_calendar_list_events via MCP")
|
||||
|
||||
# Get today and next week for date range
|
||||
today = datetime.now()
|
||||
next_week = today + timedelta(days=7)
|
||||
start_date = today.strftime("%Y-%m-%d")
|
||||
end_date = next_week.strftime("%Y-%m-%d")
|
||||
|
||||
list_events_data = {
|
||||
"calendar_name": calendar_name,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"limit": 50,
|
||||
"location_contains": "MCP Test",
|
||||
"title_contains": unique_suffix,
|
||||
}
|
||||
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_events", list_events_data
|
||||
)
|
||||
|
||||
assert list_result.isError is False, (
|
||||
f"MCP list events failed: {list_result.content}"
|
||||
)
|
||||
|
||||
events_data = json.loads(list_result.content[0].text)
|
||||
|
||||
# Debug output to understand what nc_calendar_list_events returns
|
||||
logger.info(f"list_events result type: {type(events_data)}")
|
||||
logger.info(f"list_events result content: {events_data}")
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
events_data = [events_data]
|
||||
|
||||
assert isinstance(events_data, list), "Expected events list"
|
||||
|
||||
# Our created event should be in the list
|
||||
found_event = None
|
||||
for event in events_data:
|
||||
if event.get("uid") == event_uid:
|
||||
found_event = event
|
||||
break
|
||||
|
||||
assert found_event is not None, (
|
||||
f"Created event {event_uid} not found in events list"
|
||||
)
|
||||
assert found_event["title"] == test_event_title
|
||||
|
||||
# 6. Test list events across all calendars
|
||||
logger.info("Testing nc_calendar_list_events across all calendars")
|
||||
|
||||
all_calendars_data = {
|
||||
"calendar_name": "", # Will be ignored
|
||||
"search_all_calendars": True,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"title_contains": unique_suffix,
|
||||
}
|
||||
|
||||
all_list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_events", all_calendars_data
|
||||
)
|
||||
|
||||
assert all_list_result.isError is False, (
|
||||
f"MCP list all events failed: {all_list_result.content}"
|
||||
)
|
||||
|
||||
all_events_data = json.loads(all_list_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(all_events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
all_events_data = [all_events_data]
|
||||
|
||||
assert isinstance(all_events_data, list), "Expected events list"
|
||||
|
||||
# Our event should still be found when searching all calendars
|
||||
found_in_all = any(event.get("uid") == event_uid for event in all_events_data)
|
||||
assert found_in_all, "Event not found when searching all calendars"
|
||||
|
||||
# 7. Update event via MCP
|
||||
updated_title = f"Updated {test_event_title}"
|
||||
updated_description = f"Updated description {unique_suffix}"
|
||||
|
||||
update_data = {
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"title": updated_title,
|
||||
"description": updated_description,
|
||||
"priority": 1,
|
||||
}
|
||||
|
||||
logger.info(f"Updating event via MCP: {event_uid}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event", update_data
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP event update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 8. Verify update via direct NextcloudClient
|
||||
updated_direct_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert updated_direct_event["title"] == updated_title
|
||||
assert updated_direct_event["description"] == updated_description
|
||||
assert updated_direct_event["priority"] == 1
|
||||
|
||||
# 9. Test upcoming events via MCP
|
||||
logger.info("Testing nc_calendar_get_upcoming_events via MCP")
|
||||
upcoming_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_get_upcoming_events",
|
||||
{"calendar_name": calendar_name, "days_ahead": 7, "limit": 10},
|
||||
)
|
||||
|
||||
assert upcoming_result.isError is False, (
|
||||
f"MCP upcoming events failed: {upcoming_result.content}"
|
||||
)
|
||||
|
||||
upcoming_events = json.loads(upcoming_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as other tools)
|
||||
if isinstance(upcoming_events, dict):
|
||||
# Single event returned as dict instead of list
|
||||
upcoming_events = [upcoming_events]
|
||||
|
||||
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||
|
||||
# 10. Delete event via MCP
|
||||
logger.info(f"Deleting event via MCP: {event_uid}")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_delete_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, (
|
||||
f"MCP event deletion failed: {delete_result.content}"
|
||||
)
|
||||
|
||||
# 11. Verify deletion via direct NextcloudClient
|
||||
try:
|
||||
await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
pytest.fail("Event should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - event should be deleted
|
||||
logger.info(f"Successfully verified event {event_uid} was deleted")
|
||||
created_event = None # Mark as cleaned up
|
||||
|
||||
except Exception as e:
|
||||
if "Calendar app may not be enabled" in str(
|
||||
e
|
||||
) or "No calendars available" in str(e):
|
||||
pytest.skip("Calendar functionality not available for testing")
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Cleanup in case of test failure
|
||||
if created_event is not None:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(
|
||||
created_event["calendar_name"], created_event["uid"]
|
||||
)
|
||||
logger.info(
|
||||
f"Cleaned up event {created_event['uid']} after test failure"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup event: {e}")
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
import logging
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
import logging
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from typing import Dict, Any
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.fixture(scope="module")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Integration tests for WebDAV operations."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_base_path(nc_client: NextcloudClient):
|
||||
"""Base path for test files/directories."""
|
||||
test_dir = f"mcp_test_{uuid.uuid4().hex[:8]}"
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
yield test_dir
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
|
||||
|
||||
async def test_create_and_delete_directory(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating and deleting directories."""
|
||||
test_dir = f"{test_base_path}/test_directory"
|
||||
|
||||
try:
|
||||
# Create directory
|
||||
result = await nc_client.webdav.create_directory(test_dir)
|
||||
assert result["status_code"] == 201 # Created
|
||||
logger.info(f"Created directory: {test_dir}")
|
||||
|
||||
# Verify directory exists by listing parent
|
||||
parent_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
dir_names = [item["name"] for item in parent_listing]
|
||||
assert "test_directory" in dir_names
|
||||
|
||||
# Delete directory
|
||||
delete_result = await nc_client.webdav.delete_resource(test_dir)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted directory: {test_dir}")
|
||||
|
||||
finally:
|
||||
# Cleanup: ensure directory is deleted
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test writing, reading, and deleting files."""
|
||||
test_file = f"{test_base_path}/test_file.txt"
|
||||
test_content = f"Test content {uuid.uuid4().hex}"
|
||||
|
||||
try:
|
||||
# Create base directory first
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write file
|
||||
write_result = await nc_client.webdav.write_file(
|
||||
test_file, test_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert write_result["status_code"] in [200, 201, 204] # Success codes
|
||||
logger.info(f"Wrote file: {test_file}")
|
||||
|
||||
# Read file back
|
||||
content, content_type = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == test_content
|
||||
assert "text/plain" in content_type
|
||||
logger.info(f"Read file: {test_file}")
|
||||
|
||||
# Verify file appears in directory listing
|
||||
listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
file_names = [item["name"] for item in listing]
|
||||
assert "test_file.txt" in file_names
|
||||
|
||||
# Delete file
|
||||
delete_result = await nc_client.webdav.delete_resource(test_file)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_directory_empty_and_populated(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test listing empty and populated directories."""
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# List empty directory
|
||||
empty_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert isinstance(empty_listing, list)
|
||||
assert len(empty_listing) == 0
|
||||
logger.info(f"Empty directory listing: {len(empty_listing)} items")
|
||||
|
||||
# Add some files and directories
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file1.txt", b"content1", content_type="text/plain"
|
||||
)
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file2.md",
|
||||
b"# Markdown content",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
# List populated directory
|
||||
populated_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert len(populated_listing) == 4 # 2 dirs + 2 files
|
||||
|
||||
# Check that we have both files and directories
|
||||
names = [item["name"] for item in populated_listing]
|
||||
assert "subdir1" in names
|
||||
assert "subdir2" in names
|
||||
assert "file1.txt" in names
|
||||
assert "file2.md" in names
|
||||
|
||||
# Check metadata is present
|
||||
for item in populated_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Populated directory listing: {len(populated_listing)} items")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file1.txt")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file2.md")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_read_nonexistent_file(nc_client: NextcloudClient):
|
||||
"""Test reading a file that doesn't exist."""
|
||||
nonexistent_file = f"nonexistent_{uuid.uuid4().hex}.txt"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.webdav.read_file(nonexistent_file)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
"""Test deleting a resource that doesn't exist."""
|
||||
nonexistent_resource = f"nonexistent_{uuid.uuid4().hex}"
|
||||
|
||||
result = await nc_client.webdav.delete_resource(nonexistent_resource)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}")
|
||||
|
||||
|
||||
async def test_create_nested_directories(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating nested directory structures."""
|
||||
nested_path = f"{test_base_path}/level1/level2/level3"
|
||||
|
||||
try:
|
||||
# Create nested directories (should create parent directories automatically)
|
||||
result = await nc_client.webdav.create_directory(nested_path, True)
|
||||
assert result["status_code"] == 201
|
||||
|
||||
# Verify the structure was created
|
||||
level1_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1"
|
||||
)
|
||||
assert len(level1_listing) == 1
|
||||
assert level1_listing[0]["name"] == "level2"
|
||||
assert level1_listing[0]["is_directory"] is True
|
||||
|
||||
level2_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1/level2"
|
||||
)
|
||||
assert len(level2_listing) == 1
|
||||
assert level2_listing[0]["name"] == "level3"
|
||||
assert level2_listing[0]["is_directory"] is True
|
||||
|
||||
logger.info(f"Created nested directory structure: {nested_path}")
|
||||
|
||||
finally:
|
||||
# Cleanup - delete from deepest to shallowest
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(nested_path)
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1/level2")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test overwriting an existing file."""
|
||||
test_file = f"{test_base_path}/overwrite_test.txt"
|
||||
original_content = "Original content"
|
||||
new_content = "New content after overwrite"
|
||||
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write original file
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, original_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
|
||||
# Verify original content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == original_content
|
||||
|
||||
# Overwrite with new content
|
||||
overwrite_result = await nc_client.webdav.write_file(
|
||||
test_file, new_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert overwrite_result["status_code"] in [200, 204] # OK or No Content
|
||||
|
||||
# Verify new content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == new_content
|
||||
|
||||
logger.info(f"Successfully overwrote file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_root_directory(nc_client: NextcloudClient):
|
||||
"""Test listing the root directory."""
|
||||
root_listing = await nc_client.webdav.list_directory("")
|
||||
|
||||
# Root directory should exist and be listable
|
||||
assert isinstance(root_listing, list)
|
||||
# Should have at least some default folders/files
|
||||
assert len(root_listing) >= 0
|
||||
|
||||
# Check structure of items
|
||||
for item in root_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Root directory contains {len(root_listing)} items")
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
@@ -43,6 +43,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
@@ -270,6 +279,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "6.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -346,6 +368,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
@@ -420,12 +469,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.9.0"
|
||||
version = "1.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -433,9 +483,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/68/63045305f29ff680a9cd5be360c755270109e6b76f696ea6824547ddbc30/mcp-1.10.1.tar.gz", hash = "sha256:aaa0957d8307feeff180da2d9d359f2b801f35c0c67f1882136239055ef034c2", size = 392969, upload-time = "2025-06-27T12:03:08.982Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/3f/435a5b3d10ae242a9d6c2b33175551173c3c61fe637dc893be05c4ed0aaf/mcp-1.10.1-py3-none-any.whl", hash = "sha256:4d08301aefe906dce0fa482289db55ce1db831e3e67212e65b5e23ad8454b3c5", size = 150878, upload-time = "2025-06-27T12:03:07.328Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -455,12 +505,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.3.0"
|
||||
version = "0.7.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pythonvcard4" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -476,8 +529,11 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.9,<1.10" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.10,<1.11" },
|
||||
{ name = "pillow", specifier = ">=11.2.1,<12.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.4" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -761,6 +817,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
@@ -779,6 +847,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pythonvcard4"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/04/02d5952a9d8cbcb9e62b4fc4f6f842e8d43aead6e307f83e6fd6f7352fbd/pythonvcard4-0.2.0.tar.gz", hash = "sha256:236bba2769e459645cfa776407ff07856aced45b437116bf40ddb39bbcefdb6d", size = 5530, upload-time = "2025-04-26T23:18:48.963Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2f/ee10d88bbe12e4e9e06f81589d999687038e5cd5fec6c05aed57c50aede6/pythonvcard4-0.2.0-py3-none-any.whl", hash = "sha256:dce31355dd50aee537f8883de86f301510e407bc1755a68ec8d5055b64f5c660", size = 5890, upload-time = "2025-04-26T23:18:48.2Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
@@ -826,6 +903,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747, upload-time = "2024-12-29T11:49:16.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.0.0"
|
||||
@@ -839,6 +930,80 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304, upload-time = "2025-05-21T12:46:12.502Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/e1/df13fe3ddbbea43567e07437f097863b20c99318ae1f58a0fe389f763738/rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d", size = 373341, upload-time = "2025-05-21T12:43:02.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/deef4d30fcbcbfef3b6d82d17c64490d5c94585a2310544ce8e2d3024f83/rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255", size = 359111, upload-time = "2025-05-21T12:43:05.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/7e/39f1f4431b03e96ebaf159e29a0f82a77259d8f38b2dd474721eb3a8ac9b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2", size = 386112, upload-time = "2025-05-21T12:43:07.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e7/847068a48d63aec2ae695a1646089620b3b03f8ccf9f02c122ebaf778f3c/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0", size = 400362, upload-time = "2025-05-21T12:43:08.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/3d/9441d5db4343d0cee759a7ab4d67420a476cebb032081763de934719727b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f", size = 522214, upload-time = "2025-05-21T12:43:10.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ec/2cc5b30d95f9f1a432c79c7a2f65d85e52812a8f6cbf8768724571710786/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7", size = 411491, upload-time = "2025-05-21T12:43:12.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/6c/44695c1f035077a017dd472b6a3253553780837af2fac9b6ac25f6a5cb4d/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd", size = 386978, upload-time = "2025-05-21T12:43:14.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/74/b4357090bb1096db5392157b4e7ed8bb2417dc7799200fcbaee633a032c9/rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65", size = 420662, upload-time = "2025-05-21T12:43:15.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/dd/8cadbebf47b96e59dfe8b35868e5c38a42272699324e95ed522da09d3a40/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f", size = 563385, upload-time = "2025-05-21T12:43:17.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/ea/92960bb7f0e7a57a5ab233662f12152085c7dc0d5468534c65991a3d48c9/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d", size = 592047, upload-time = "2025-05-21T12:43:19.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/71aabc93df0d05dabcb4b0c749277881f8e74548582d96aa1bf24379493a/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042", size = 557863, upload-time = "2025-05-21T12:43:21.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0f/89df0067c41f122b90b76f3660028a466eb287cbe38efec3ea70e637ca78/rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc", size = 219627, upload-time = "2025-05-21T12:43:23.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8d/93b1a4c1baa903d0229374d9e7aa3466d751f1d65e268c52e6039c6e338e/rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4", size = 231603, upload-time = "2025-05-21T12:43:25.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/11/392605e5247bead2f23e6888e77229fbd714ac241ebbebb39a1e822c8815/rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4", size = 223967, upload-time = "2025-05-21T12:43:26.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647, upload-time = "2025-05-21T12:43:28.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454, upload-time = "2025-05-21T12:43:30.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665, upload-time = "2025-05-21T12:43:32.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873, upload-time = "2025-05-21T12:43:34.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866, upload-time = "2025-05-21T12:43:36.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886, upload-time = "2025-05-21T12:43:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666, upload-time = "2025-05-21T12:43:40.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109, upload-time = "2025-05-21T12:43:42.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244, upload-time = "2025-05-21T12:43:43.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023, upload-time = "2025-05-21T12:43:45.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634, upload-time = "2025-05-21T12:43:48.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713, upload-time = "2025-05-21T12:43:49.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280, upload-time = "2025-05-21T12:43:51.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399, upload-time = "2025-05-21T12:43:53.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/da/323848a2b62abe6a0fec16ebe199dc6889c5d0a332458da8985b2980dffe/rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559", size = 364498, upload-time = "2025-05-21T12:43:54.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b4/4d3820f731c80fd0cd823b3e95b9963fec681ae45ba35b5281a42382c67d/rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1", size = 350083, upload-time = "2025-05-21T12:43:56.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b1/3a8ee1c9d480e8493619a437dec685d005f706b69253286f50f498cbdbcf/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c", size = 389023, upload-time = "2025-05-21T12:43:57.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/31/17293edcfc934dc62c3bf74a0cb449ecd549531f956b72287203e6880b87/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb", size = 403283, upload-time = "2025-05-21T12:43:59.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ca/e0f0bc1a75a8925024f343258c8ecbd8828f8997ea2ac71e02f67b6f5299/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40", size = 524634, upload-time = "2025-05-21T12:44:01.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/5d0be919037178fff33a6672ffc0afa04ea1cfcb61afd4119d1b5280ff0f/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79", size = 416233, upload-time = "2025-05-21T12:44:02.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7c/8abb70f9017a231c6c961a8941403ed6557664c0913e1bf413cbdc039e75/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325", size = 390375, upload-time = "2025-05-21T12:44:04.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ac/a87f339f0e066b9535074a9f403b9313fd3892d4a164d5d5f5875ac9f29f/rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295", size = 424537, upload-time = "2025-05-21T12:44:06.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8f/8d5c1567eaf8c8afe98a838dd24de5013ce6e8f53a01bd47fe8bb06b5533/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b", size = 566425, upload-time = "2025-05-21T12:44:08.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/33/03016a6be5663b389c8ab0bbbcca68d9e96af14faeff0a04affcb587e776/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98", size = 595197, upload-time = "2025-05-21T12:44:10.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8d/da9f4d3e208c82fda311bff0cf0a19579afceb77cf456e46c559a1c075ba/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd", size = 561244, upload-time = "2025-05-21T12:44:12.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b3/39d5dcf7c5f742ecd6dbc88f6f84ae54184b92f5f387a4053be2107b17f1/rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31", size = 222254, upload-time = "2025-05-21T12:44:14.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/19/2d6772c8eeb8302c5f834e6d0dfd83935a884e7c5ce16340c7eaf89ce925/rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500", size = 234741, upload-time = "2025-05-21T12:44:16.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/5a/145ada26cfaf86018d0eb304fe55eafdd4f0b6b84530246bb4a7c4fb5c4b/rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5", size = 224830, upload-time = "2025-05-21T12:44:17.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ca/d435844829c384fd2c22754ff65889c5c556a675d2ed9eb0e148435c6690/rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129", size = 359668, upload-time = "2025-05-21T12:44:19.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/b056f21db3a09f89410d493d2f6614d87bb162499f98b649d1dbd2a81988/rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d", size = 345649, upload-time = "2025-05-21T12:44:20.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0f/e0d00dc991e3d40e03ca36383b44995126c36b3eafa0ccbbd19664709c88/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72", size = 384776, upload-time = "2025-05-21T12:44:22.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a2/59374837f105f2ca79bde3c3cd1065b2f8c01678900924949f6392eab66d/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34", size = 395131, upload-time = "2025-05-21T12:44:24.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/dc/48e8d84887627a0fe0bac53f0b4631e90976fd5d35fff8be66b8e4f3916b/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9", size = 520942, upload-time = "2025-05-21T12:44:25.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f5/ee056966aeae401913d37befeeab57a4a43a4f00099e0a20297f17b8f00c/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5", size = 411330, upload-time = "2025-05-21T12:44:27.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/74/b2cffb46a097cefe5d17f94ede7a174184b9d158a0aeb195f39f2c0361e8/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194", size = 387339, upload-time = "2025-05-21T12:44:29.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9a/0ff0b375dcb5161c2b7054e7d0b7575f1680127505945f5cabaac890bc07/rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6", size = 418077, upload-time = "2025-05-21T12:44:30.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/a1/fda629bf20d6b698ae84c7c840cfb0e9e4200f664fc96e1f456f00e4ad6e/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78", size = 562441, upload-time = "2025-05-21T12:44:32.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/15/ce4b5257f654132f326f4acd87268e1006cc071e2c59794c5bdf4bebbb51/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72", size = 590750, upload-time = "2025-05-21T12:44:34.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/ab/e04bf58a8d375aeedb5268edcc835c6a660ebf79d4384d8e0889439448b0/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66", size = 558891, upload-time = "2025-05-21T12:44:37.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/82/cb8c6028a6ef6cd2b7991e2e4ced01c854b6236ecf51e81b64b569c43d73/rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523", size = 218718, upload-time = "2025-05-21T12:44:38.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/97/5a4b59697111c89477d20ba8a44df9ca16b41e737fa569d5ae8bff99e650/rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763", size = 232218, upload-time = "2025-05-21T12:44:40.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/74/48f3df0715a585cbf5d34919c9c757a4c92c1a9eba059f2d334e72471f70/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954", size = 374208, upload-time = "2025-05-21T12:45:26.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b0/9b01bb11ce01ec03d05e627249cc2c06039d6aa24ea5a22a39c312167c10/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba", size = 359262, upload-time = "2025-05-21T12:45:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/eb/5395621618f723ebd5116c53282052943a726dba111b49cd2071f785b665/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b", size = 387366, upload-time = "2025-05-21T12:45:30.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/73/3d51442bdb246db619d75039a50ea1cf8b5b4ee250c3e5cd5c3af5981cd4/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038", size = 400759, upload-time = "2025-05-21T12:45:32.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4c/3a32d5955d7e6cb117314597bc0f2224efc798428318b13073efe306512a/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9", size = 523128, upload-time = "2025-05-21T12:45:34.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/95/1ffccd3b0bb901ae60b1dd4b1be2ab98bb4eb834cd9b15199888f5702f7b/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1", size = 411597, upload-time = "2025-05-21T12:45:36.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/6d/6e6cd310180689db8b0d2de7f7d1eabf3fb013f239e156ae0d5a1a85c27f/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762", size = 388053, upload-time = "2025-05-21T12:45:38.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/87/ec4186b1fe6365ced6fa470960e68fc7804bafbe7c0cf5a36237aa240efa/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e", size = 421821, upload-time = "2025-05-21T12:45:40.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/60/84f821f6bf4e0e710acc5039d91f8f594fae0d93fc368704920d8971680d/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692", size = 564534, upload-time = "2025-05-21T12:45:42.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3a/bc654eb15d3b38f9330fe0f545016ba154d89cdabc6177b0295910cd0ebe/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf", size = 592674, upload-time = "2025-05-21T12:45:44.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/ba/31239736f29e4dfc7a58a45955c5db852864c306131fd6320aea214d5437/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe", size = 558781, upload-time = "2025-05-21T12:45:46.281Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.13"
|
||||
@@ -873,6 +1038,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -989,7 +1163,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.15.3"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -997,9 +1171,9 @@ dependencies = [
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload-time = "2025-04-28T21:40:59.204Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload-time = "2025-04-28T21:40:56.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1023,6 +1197,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.2"
|
||||
|
||||
Reference in New Issue
Block a user