Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e3c2c9774 | |||
| 752c22147c | |||
| 4c07ca9f0a | |||
| 55945c6c0f | |||
| 3f8312e6f3 | |||
| c39b69d08c | |||
| 290ad2edc2 | |||
| 144c08c339 | |||
| b461af8aa1 | |||
| 4bdf67b042 | |||
| 93b109e5b9 | |||
| 0c5ebd5d84 | |||
| 79e6250377 | |||
| a5ec712b88 | |||
| cc9650b077 | |||
| 1a37a6c1fe | |||
| 4572287870 | |||
| 67617d7fcc | |||
| 22811f29f6 | |||
| 71da620099 | |||
| de7c848aa6 | |||
| 8d4303a624 | |||
| 4c7880a4e5 | |||
| 0a307b87ae | |||
| 48eced80fb | |||
| aafac732c6 | |||
| 12d48bb920 | |||
| 0600cea87b | |||
| 145141e1d8 | |||
| 948e7a4d91 | |||
| 39ff811d1a | |||
| cfd03a761b | |||
| e7b37312a7 | |||
| 4ad47b4fa3 | |||
| ffbb86df57 | |||
| 7a57247a9c | |||
| 4ea6ce3477 | |||
| fad2cd8dcb | |||
| 06042357f8 | |||
| 5bdf840098 | |||
| 9711d1d161 | |||
| 2d802483e5 | |||
| b3cd2ace34 | |||
| 2cd91ceee7 | |||
| 84106a059e | |||
| c1c5a61952 | |||
| e7c4eb0842 | |||
| 2f60dec90d | |||
| 59633017b0 | |||
| 6fa59621bf | |||
| c2284298ce | |||
| 7498b501eb | |||
| 652c58d1fb | |||
| e7a5caa0d6 | |||
| d2d413afcd | |||
| 3c3df0d3a5 | |||
| c59bcca053 | |||
| 18973e061a | |||
| 167053578d | |||
| 2633b63a04 | |||
| 5d4902a73e | |||
| b55b9640c6 | |||
| b1eb4d2497 | |||
| 6c580fec01 | |||
| bbd8d1cf63 | |||
| d01c6ee0d0 | |||
| d48b93e8fc | |||
| 7b663c5476 | |||
| 73257e749f | |||
| d66faa9533 | |||
| 58fd0283ea | |||
| 3feac952da | |||
| 6a2ed9815a | |||
| c1c01196a4 | |||
| 930bb280fe | |||
| e36d020f6b | |||
| c13240819a | |||
| c2c2a71c4b | |||
| 21f6164e07 | |||
| 420fa9173d | |||
| da4d48c493 | |||
| 404abe8695 | |||
| 28dd24510d | |||
| f72bb7e996 | |||
| 9c0a0e9bf3 | |||
| 78b96177bd | |||
| 70b0754a19 | |||
| f034012101 | |||
| 7c4c0284f3 | |||
| 892340fb66 | |||
| f79b957644 | |||
| ef1fb9e9aa | |||
| d712b5487c | |||
| 892a8d2d23 | |||
| daeb95f3c3 | |||
| 36d44d1781 | |||
| 949fb7124b | |||
| 6c4f071d2b | |||
| 53b11f7fbb | |||
| 336bc45637 | |||
| 6c587bb265 | |||
| 6b1f5c12c8 | |||
| f8dc1f060b |
+1
-2
@@ -1,8 +1,7 @@
|
||||
*
|
||||
|
||||
!pyproject.toml
|
||||
!poetry.lock
|
||||
!README.md
|
||||
!uv.lock
|
||||
|
||||
!nextcloud_mcp_server/
|
||||
!nextcloud_mcp_server/**/*.py
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@4959332f0f014c5280e7eac8b70c90cb574c9f9b # v6
|
||||
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@4959332f0f014c5280e7eac8b70c90cb574c9f9b # v6
|
||||
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.3
|
||||
rev: v4.9.0
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
|
||||
@@ -1,3 +1,94 @@
|
||||
## v0.12.4 (2025-09-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.15,<1.16
|
||||
|
||||
## v0.12.3 (2025-09-23)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Add tools for all resources to enable tool-only workflows
|
||||
|
||||
## v0.12.2 (2025-09-20)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Add `http` to --transport option
|
||||
|
||||
## v0.12.1 (2025-09-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **docker**: Provide --host 0.0.0.0 in default docker image
|
||||
|
||||
## v0.12.0 (2025-09-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for `streamable-http` transport type
|
||||
|
||||
## v0.11.1 (2025-09-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.13,<1.14
|
||||
|
||||
## v0.11.0 (2025-09-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **deck**: Add support for stack, cards, labels
|
||||
- **deck**: Initialize Deck app client/server
|
||||
|
||||
## v0.10.0 (2025-09-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add WebDAV resource copy functionality
|
||||
- Add WebDAV resource move/rename functionality
|
||||
|
||||
## v0.9.0 (2025-09-10)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- FASTMCP_-prefixed env vars have been replaced by CLI
|
||||
arguments. Refer to the README for updated usage.
|
||||
|
||||
### Feat
|
||||
|
||||
- **cli**: Replace `mcp run` with click CLI and runtime options
|
||||
|
||||
## v0.8.3 (2025-08-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **server**: Replace ErrorResponses with standard McpErrors
|
||||
- **notes**: Include ETags in responses to avoid accidently updates
|
||||
|
||||
## v0.8.2 (2025-08-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **notes**: Remove note contents from responses to reduce token usage
|
||||
|
||||
## v0.8.1 (2025-08-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **model**: Serialize timestamps in RFC3339 format
|
||||
|
||||
## v0.8.0 (2025-08-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- **client**: Preserve fields when modifying contacts/calendar resources
|
||||
- **server**: Add structured output to all tool/resource output
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use _make_request where available
|
||||
|
||||
## v0.7.2 (2025-08-30)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -102,13 +102,19 @@ Each Nextcloud app has a corresponding server module that:
|
||||
- **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`
|
||||
- **MANDATORY: Always run tests after implementing features or fixing bugs**
|
||||
- Run tests to completion before considering any task complete
|
||||
- If tests require modifications to pass, ask for permission before proceeding
|
||||
- Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes
|
||||
- **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
|
||||
- **Test specific functionality** after changes:
|
||||
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
### Configuration Files
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.8.14-python3.11-alpine@sha256:7b1463148981d57ed2d9c2950f570fe5fdd88570970f9f56f6e0e5a8829eca95
|
||||
FROM ghcr.io/astral-sh/uv:0.8.22-python3.11-alpine@sha256:a8d5f7079a3223380ec060fefe48afe45b4c4622d631ce0e495593ac9a38f546
|
||||
|
||||
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/app.py:mcp"]
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -17,212 +17,44 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| **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. |
|
||||
| **Deck** | ✅ Full Support | Complete project management - boards, stacks, cards, labels, user assignments. Full CRUD operations and advanced features. |
|
||||
| **Tasks** | ❌ [Not Started](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) | TBD |
|
||||
|
||||
## Available Tools
|
||||
Is there a Nextcloud app not present in this list that you'd like to be
|
||||
included? Feel free to open an issue, or contribute via a pull-request.
|
||||
|
||||
### Notes Tools
|
||||
## Available Tools & Resources
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_get_note` | Get a specific note by ID |
|
||||
| `nc_notes_create_note` | Create a new note with title, content, and category |
|
||||
| `nc_notes_update_note` | Update an existing note by ID |
|
||||
| `nc_notes_append_content` | Append content to an existing note with a clear separator |
|
||||
| `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 |
|
||||
|------|-------------|
|
||||
| `nc_tables_list_tables` | List all tables available to the user |
|
||||
| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views |
|
||||
| `nc_tables_read_table` | Read rows from a table with optional pagination |
|
||||
| `nc_tables_insert_row` | Insert a new row into a table |
|
||||
| `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
|
||||
Resources provide read-only access to data for browsing and discovery. Unlike tools, resources are automatically listed by MCP clients and enable LLMs to explore your Nextcloud data structure.
|
||||
|
||||
### Core Resources
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `nc://capabilities` | Access Nextcloud server capabilities |
|
||||
| `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:
|
||||
### Tools vs Resources
|
||||
|
||||
- Browse any directory structure
|
||||
- Read and write files of any type
|
||||
- Create and delete directories
|
||||
- Manage your NextCloud files directly through LLM interactions
|
||||
**Tools** are for actions and operations:
|
||||
- Create, update, delete operations
|
||||
- Structured responses with validation
|
||||
- Error handling and business logic
|
||||
- Examples: `deck_create_card`, `deck_update_stack`
|
||||
|
||||
**Usage Examples:**
|
||||
**Resources** are for data browsing and discovery:
|
||||
- Read-only access to existing data
|
||||
- Automatic listing by MCP clients
|
||||
- Raw data format for exploration
|
||||
- Examples: `nc://Deck/boards/{board_id}`, `nc://Deck/boards/{board_id}/stacks`
|
||||
|
||||
```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:
|
||||
|
||||
* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app.
|
||||
* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time.
|
||||
* WebDAV permissions must be properly configured for attachment operations to work correctly.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Python 3.8+
|
||||
* Python 3.11+
|
||||
* Access to a Nextcloud instance
|
||||
|
||||
### Local Installation
|
||||
@@ -232,9 +64,30 @@ This server supports adding and retrieving note attachments via WebDAV. Please n
|
||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
||||
cd nextcloud-mcp-server
|
||||
```
|
||||
2. Install the package (if running as a library):
|
||||
2. Install the package dependencies (if running via CLI):
|
||||
```bash
|
||||
poetry install
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. Run the CLI --help command to see all available options
|
||||
```bash
|
||||
$ uv run python -m nextcloud_mcp_server.app --help
|
||||
Usage: python -m nextcloud_mcp_server.app [OPTIONS]
|
||||
|
||||
Options:
|
||||
-h, --host TEXT [default: 127.0.0.1]
|
||||
-p, --port INTEGER [default: 8000]
|
||||
-w, --workers INTEGER
|
||||
-r, --reload
|
||||
-l, --log-level [critical|error|warning|info|debug|trace]
|
||||
[default: info]
|
||||
-t, --transport [sse|streamable-http]
|
||||
[default: sse]
|
||||
-e, --enable-app [notes|tables|webdav|calendar|contacts|deck]
|
||||
Enable specific Nextcloud app APIs. Can be
|
||||
specified multiple times. If not specified,
|
||||
all apps are enabled.
|
||||
--help Show this message and exit.
|
||||
```
|
||||
|
||||
### Docker
|
||||
@@ -255,45 +108,137 @@ 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`)
|
||||
|
||||
## Transport Types
|
||||
|
||||
The server supports two transport types for MCP communication:
|
||||
|
||||
### Streamable HTTP (Recommended)
|
||||
The `streamable-http` transport is the recommended and modern transport type that provides improved streaming capabilities:
|
||||
|
||||
```bash
|
||||
# Use streamable-http transport (recommended)
|
||||
uv run python -m nextcloud_mcp_server.app --transport streamable-http
|
||||
```
|
||||
|
||||
### SSE (Server-Sent Events) - Deprecated
|
||||
> [!WARNING]
|
||||
> ⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version of the MCP spec. SSE will be supported for the foreseable future, but users are encouraged to switch to the new transport type. Please migrate to `streamable-http`.
|
||||
|
||||
```bash
|
||||
# SSE transport (deprecated - for backwards compatibility only)
|
||||
uv run python -m nextcloud_mcp_server.app --transport sse
|
||||
```
|
||||
|
||||
#### Docker Usage with Transports
|
||||
|
||||
```bash
|
||||
# Using SSE transport (default - deprecated)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# Using streamable-http transport (recommended)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--transport streamable-http
|
||||
```
|
||||
|
||||
**Note:** When using MCP clients, ensure your client supports the transport type you've configured on the server. Most modern MCP clients support streamable-http.
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Locally
|
||||
|
||||
Ensure your environment variables are loaded, then run the server using `mcp run`:
|
||||
Ensure your environment variables are loaded, then run the server. You have several options:
|
||||
|
||||
#### Option 1: Using `nextcloud_mcp_server` cli (recommended)
|
||||
```bash
|
||||
# Load environment variables from your .env file
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run the app module directly with custom options
|
||||
uv run python -m nextcloud_mcp_server.app --host 0.0.0.0 --port 8080 --log-level info
|
||||
|
||||
# Enable only specific Nextcloud app APIs
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar
|
||||
|
||||
# Enable only WebDAV for file operations
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app webdav
|
||||
```
|
||||
|
||||
#### Option 2: Using `uvicorn`
|
||||
|
||||
You can also run the MCP server with `uvicorn` directly, which enables support
|
||||
for all uvicorn arguments (e.g. `--reload`, `--workers`).
|
||||
|
||||
```bash
|
||||
# Load environment variables from your .env file
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run the server
|
||||
mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
# Run with uvicorn using the --factory option
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app --factory --reload --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
The server will start, typically listening on `http://localhost:8000`.
|
||||
The server will start, typically listening on `http://127.0.0.1:8000`.
|
||||
|
||||
> NOTE: To make the server bind to a different address, use the FASTMCP_HOST environmental variable
|
||||
**Host binding options:**
|
||||
- Use `--host 0.0.0.0` to bind to all interfaces
|
||||
- Use `--host 127.0.0.1` to bind only to localhost (default)
|
||||
|
||||
See the full list of available `uvicorn` options and how to set them at [https://www.uvicorn.org/settings/]()
|
||||
|
||||
### Selective App Enablement
|
||||
|
||||
By default, all supported Nextcloud app APIs are enabled. You can selectively enable only specific apps using the `--enable-app` option:
|
||||
|
||||
```bash
|
||||
# Available apps: notes, tables, webdav, calendar, contacts, deck
|
||||
|
||||
# Enable all apps (default behavior)
|
||||
uv run python -m nextcloud_mcp_server.app
|
||||
|
||||
# Enable only Notes and Calendar
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar
|
||||
|
||||
# Enable only WebDAV for file operations
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app webdav
|
||||
|
||||
# Enable multiple apps by repeating the option
|
||||
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app tables --enable-app contacts
|
||||
```
|
||||
|
||||
This can be useful for:
|
||||
- Reducing memory usage and startup time
|
||||
- Limiting available functionality for security or organizational reasons
|
||||
- Testing specific app integrations
|
||||
- Running lightweight instances with only needed features
|
||||
|
||||
### Using Docker
|
||||
|
||||
Mount your environment file when running the container:
|
||||
|
||||
```bash
|
||||
# Run with all apps enabled (default)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# Run with only specific apps enabled
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--enable-app notes --enable-app calendar
|
||||
|
||||
# Run with only WebDAV
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--enable-app webdav
|
||||
```
|
||||
|
||||
This will start the server and expose it on port 8000 of your local machine.
|
||||
|
||||
## Usage
|
||||
|
||||
Once the server is running, you can connect to it using an MCP client like `uvx`. Add the server to your `uvx` configuration:
|
||||
Once the server is running, you can connect to it using an MCP client like `MCP Inspector`. Once your MCP server is running, launch MCP Inspector as follows:
|
||||
|
||||
```bash
|
||||
uvx mcp add nextcloud-mcp http://localhost:8000 --default-transport sse
|
||||
uv run mcp dev
|
||||
```
|
||||
|
||||
You can then interact with the server's tools and resources through your LLM interface connected to `uvx`.
|
||||
You can then connect to and interact with the server's tools and resources through your browser.
|
||||
|
||||
## References:
|
||||
|
||||
@@ -303,6 +248,10 @@ You can then interact with the server's tools and resources through your LLM int
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ app:enable deck
|
||||
+7
-5
@@ -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:272084c2dec70619714df329c4ffcb336e3f8c723072c3f56f2e4015997bbf2c
|
||||
image: mariadb:lts@sha256:851a6020c97b9eae7736b6fb275800601d64635222054d3a1b1b3c4abdfa117a
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -21,14 +21,14 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.8@sha256:3eaddb0a9c56e6cf81ad258a5d05b78f747f6434b974f9a44e3f0dd91311b6ef
|
||||
image: nextcloud:31.0.9@sha256:88fe398340a896eeebfe0a4ba847998ff2c8fbb3d72de354ac1f08bc7b44db18
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
#- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done
|
||||
#user: root
|
||||
ports:
|
||||
- 8080:80
|
||||
- 127.0.0.1:8080:80
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
@@ -46,13 +46,15 @@ services:
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http"]
|
||||
ports:
|
||||
- 8000:8000
|
||||
- 127.0.0.1:8000:8000
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_USERNAME=admin
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
- FASTMCP_HOST=0.0.0.0
|
||||
#volumes:
|
||||
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Calendar App
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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"
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contacts App
|
||||
|
||||
### 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 |
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Deck App
|
||||
|
||||
### Deck Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `deck_create_board` | Create a new Deck board with title and color |
|
||||
| `deck_create_stack` | Create a new stack in a board |
|
||||
| `deck_update_stack` | Update stack title and order |
|
||||
| `deck_delete_stack` | Delete a stack and all its cards |
|
||||
| `deck_create_card` | Create a new card in a stack with full options (title, description, due date, etc.) |
|
||||
| `deck_update_card` | Update any aspect of a card (title, description, owner, order, etc.) |
|
||||
| `deck_delete_card` | Delete a card |
|
||||
| `deck_archive_card` | Archive a card |
|
||||
| `deck_unarchive_card` | Unarchive a card |
|
||||
| `deck_reorder_card` | Move/reorder cards within or between stacks |
|
||||
| `deck_create_label` | Create a new label in a board |
|
||||
| `deck_update_label` | Update label title and color |
|
||||
| `deck_delete_label` | Delete a label |
|
||||
| `deck_assign_label_to_card` | Assign a label to a card |
|
||||
| `deck_remove_label_from_card` | Remove a label from a card |
|
||||
| `deck_assign_user_to_card` | Assign a user to a card |
|
||||
| `deck_unassign_user_from_card` | Remove a user assignment from a card |
|
||||
|
||||
### Deck Resources
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `nc://Deck/boards` | List all deck boards |
|
||||
| `nc://Deck/boards/{board_id}` | Get details of a specific board |
|
||||
| `nc://Deck/boards/{board_id}/stacks` | List all stacks in a board |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}` | Get details of a specific stack |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards` | List all cards in a stack |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` | Get details of a specific card |
|
||||
| `nc://Deck/boards/{board_id}/labels` | List all labels in a board |
|
||||
| `nc://Deck/boards/{board_id}/labels/{label_id}` | Get details of a specific label |
|
||||
|
||||
|
||||
|
||||
### Deck Project Management
|
||||
|
||||
The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows:
|
||||
|
||||
- Create and manage boards, stacks, and cards
|
||||
- Organize tasks with labels and user assignments
|
||||
- Archive/unarchive cards and reorder within or between stacks
|
||||
- Full CRUD operations on all Deck entities
|
||||
- Browse project structure through hierarchical resources
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# Create a new project board
|
||||
await deck_create_board(title="Website Redesign", color="1976D2")
|
||||
|
||||
# Create workflow stacks
|
||||
await deck_create_stack(board_id=1, title="To Do", order=1)
|
||||
await deck_create_stack(board_id=1, title="In Progress", order=2)
|
||||
await deck_create_stack(board_id=1, title="Done", order=3)
|
||||
|
||||
# Create task cards with details
|
||||
await deck_create_card(
|
||||
board_id=1,
|
||||
stack_id=1,
|
||||
title="Design new homepage",
|
||||
description="Create mockups for the new homepage layout",
|
||||
type="plain",
|
||||
order=1,
|
||||
duedate="2025-08-15T17:00:00"
|
||||
)
|
||||
|
||||
# Create and assign labels for organization
|
||||
await deck_create_label(board_id=1, title="High Priority", color="F44336")
|
||||
await deck_create_label(board_id=1, title="UI/UX", color="9C27B0")
|
||||
|
||||
# Assign labels and users to cards
|
||||
await deck_assign_label_to_card(board_id=1, stack_id=1, card_id=1, label_id=1)
|
||||
await deck_assign_user_to_card(board_id=1, stack_id=1, card_id=1, user_id="designer")
|
||||
|
||||
# Move cards through workflow
|
||||
await deck_reorder_card(
|
||||
board_id=1,
|
||||
stack_id=1, # From "To Do"
|
||||
card_id=1,
|
||||
order=1,
|
||||
target_stack_id=2 # To "In Progress"
|
||||
)
|
||||
|
||||
# Update task progress
|
||||
await deck_update_card(
|
||||
board_id=1,
|
||||
stack_id=2,
|
||||
card_id=1,
|
||||
description="Homepage mockups completed, starting development",
|
||||
order=1
|
||||
)
|
||||
|
||||
# Complete tasks
|
||||
await deck_reorder_card(
|
||||
board_id=1,
|
||||
stack_id=2, # From "In Progress"
|
||||
card_id=1,
|
||||
order=1,
|
||||
target_stack_id=3 # To "Done"
|
||||
)
|
||||
|
||||
# Archive completed cards
|
||||
await deck_archive_card(board_id=1, stack_id=3, card_id=1)
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# Notes App
|
||||
|
||||
### Notes Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_notes_create_note` | Create a new note with title, content, and category |
|
||||
| `nc_notes_update_note` | Update an existing note by ID |
|
||||
| `nc_notes_append_content` | Append content to an existing note with a clear separator |
|
||||
| `nc_notes_delete_note` | Delete a note by ID |
|
||||
| `nc_notes_search_notes` | Search notes by title or content |
|
||||
|
||||
### Note Attachments
|
||||
|
||||
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
|
||||
|
||||
* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app.
|
||||
* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time.
|
||||
* WebDAV permissions must be properly configured for attachment operations to work correctly.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tables App
|
||||
|
||||
### Tables Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_tables_list_tables` | List all tables available to the user |
|
||||
| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views |
|
||||
| `nc_tables_read_table` | Read rows from a table with optional pagination |
|
||||
| `nc_tables_insert_row` | Insert a new row into a table |
|
||||
| `nc_tables_update_row` | Update an existing row in a table |
|
||||
| `nc_tables_delete_row` | Delete a row from a table |
|
||||
@@ -0,0 +1,62 @@
|
||||
# WebDAV support
|
||||
|
||||
### 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 |
|
||||
| `nc_webdav_move_resource` | Move or rename files and directories |
|
||||
| `nc_webdav_copy_resource` | Copy files and directories |
|
||||
|
||||
### 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")
|
||||
|
||||
# Move or rename a file
|
||||
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
||||
|
||||
# Move a file to another directory
|
||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
||||
|
||||
# Move a directory
|
||||
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
||||
|
||||
# Copy a file
|
||||
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
||||
|
||||
# Copy a file to another directory
|
||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
||||
|
||||
# Copy a directory
|
||||
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
||||
```
|
||||
+118
-23
@@ -1,21 +1,28 @@
|
||||
import click
|
||||
import logging
|
||||
import uvicorn
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from dataclasses import dataclass
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
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.client import NextcloudClient
|
||||
from nextcloud_mcp_server.server import (
|
||||
configure_calendar_tools,
|
||||
configure_contacts_tools,
|
||||
configure_notes_tools,
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
configure_deck_tools,
|
||||
)
|
||||
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -37,28 +44,116 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
await client.close()
|
||||
|
||||
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
|
||||
@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()
|
||||
|
||||
# Define available apps and their configuration functions
|
||||
available_apps = {
|
||||
"notes": configure_notes_tools,
|
||||
"tables": configure_tables_tools,
|
||||
"webdav": configure_webdav_tools,
|
||||
"calendar": configure_calendar_tools,
|
||||
"contacts": configure_contacts_tools,
|
||||
"deck": configure_deck_tools,
|
||||
}
|
||||
|
||||
# If no specific apps are specified, enable all
|
||||
if enabled_apps is None:
|
||||
enabled_apps = list(available_apps.keys())
|
||||
|
||||
# Configure only the enabled apps
|
||||
for app_name in enabled_apps:
|
||||
if app_name in available_apps:
|
||||
logger.info(f"Configuring {app_name} tools")
|
||||
available_apps[app_name](mcp)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
elif transport in ("http", "streamable-http"):
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@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()
|
||||
@click.command()
|
||||
@click.option("--host", "-h", default="127.0.0.1", show_default=True)
|
||||
@click.option("--port", "-p", type=int, default=8000, show_default=True)
|
||||
@click.option("--workers", "-w", type=int, default=None)
|
||||
@click.option("--reload", "-r", is_flag=True)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
default="info",
|
||||
show_default=True,
|
||||
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
||||
)
|
||||
@click.option(
|
||||
"--transport",
|
||||
"-t",
|
||||
default="sse",
|
||||
show_default=True,
|
||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||
)
|
||||
@click.option(
|
||||
"--enable-app",
|
||||
"-e",
|
||||
multiple=True,
|
||||
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||
)
|
||||
def run(
|
||||
host: str,
|
||||
port: int,
|
||||
workers: int,
|
||||
reload: bool,
|
||||
log_level: str,
|
||||
transport: str,
|
||||
enable_app: tuple[str, ...],
|
||||
):
|
||||
enabled_apps = list(enable_app) if enable_app else None
|
||||
|
||||
if reload or workers:
|
||||
app = "nextcloud_mcp_server.app:get_app"
|
||||
factory = True
|
||||
else:
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
factory = False
|
||||
|
||||
uvicorn.run(
|
||||
app=app,
|
||||
factory=factory,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
workers=workers,
|
||||
log_level=log_level,
|
||||
)
|
||||
|
||||
|
||||
configure_notes_tools(mcp)
|
||||
configure_tables_tools(mcp)
|
||||
configure_webdav_tools(mcp)
|
||||
configure_calendar_tools(mcp)
|
||||
configure_contacts_tools(mcp)
|
||||
|
||||
|
||||
def run():
|
||||
mcp.run()
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
@@ -14,6 +14,7 @@ from httpx import (
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .deck import DeckClient
|
||||
from .notes import NotesClient
|
||||
from .tables import TablesClient
|
||||
from .webdav import WebDAVClient
|
||||
@@ -69,6 +70,7 @@ class NextcloudClient:
|
||||
self.tables = TablesClient(self._client, username)
|
||||
self.calendar = CalendarClient(self._client, username)
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
|
||||
# Initialize controllers
|
||||
self._notes_search = NotesSearchController()
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
DeckBoard,
|
||||
DeckStack,
|
||||
DeckCard,
|
||||
DeckLabel,
|
||||
DeckACL,
|
||||
DeckAttachment,
|
||||
DeckComment,
|
||||
DeckSession,
|
||||
DeckConfig,
|
||||
)
|
||||
|
||||
|
||||
class DeckClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Deck app operations."""
|
||||
|
||||
def _get_deck_headers(
|
||||
self, additional_headers: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get standard headers required for Deck API calls."""
|
||||
headers = {"OCS-APIRequest": "true", "Content-Type": "application/json"}
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
return headers
|
||||
|
||||
# Boards
|
||||
async def get_boards(
|
||||
self, details: bool = False, if_modified_since: Optional[str] = None
|
||||
) -> List[DeckBoard]:
|
||||
additional_headers = {}
|
||||
if if_modified_since:
|
||||
additional_headers["If-Modified-Since"] = if_modified_since
|
||||
headers = self._get_deck_headers(additional_headers)
|
||||
params = {"details": "true"} if details else {}
|
||||
response = await self._make_request(
|
||||
"GET", "/apps/deck/api/v1.0/boards", headers=headers, params=params
|
||||
)
|
||||
return [DeckBoard(**board) for board in response.json()]
|
||||
|
||||
async def create_board(self, title: str, color: str) -> DeckBoard:
|
||||
json_data = {"title": title, "color": color}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/deck/api/v1.0/boards", json=json_data, headers=headers
|
||||
)
|
||||
return DeckBoard(**response.json())
|
||||
|
||||
async def get_board(self, board_id: int) -> DeckBoard:
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
|
||||
)
|
||||
return DeckBoard(**response.json())
|
||||
|
||||
async def update_board(
|
||||
self,
|
||||
board_id: int,
|
||||
title: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if color is not None:
|
||||
json_data["color"] = color
|
||||
if archived is not None:
|
||||
json_data["archived"] = archived
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_board(self, board_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
|
||||
)
|
||||
|
||||
async def undo_delete_board(self, board_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/undo_delete",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def add_acl_rule(
|
||||
self,
|
||||
board_id: int,
|
||||
type: int,
|
||||
participant: str,
|
||||
permission_edit: bool,
|
||||
permission_share: bool,
|
||||
permission_manage: bool,
|
||||
) -> List[DeckACL]:
|
||||
json_data = {
|
||||
"type": type,
|
||||
"participant": participant,
|
||||
"permissionEdit": permission_edit,
|
||||
"permissionShare": permission_share,
|
||||
"permissionManage": permission_manage,
|
||||
}
|
||||
response = await self._make_request(
|
||||
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/acl", json=json_data
|
||||
)
|
||||
return [DeckACL(**acl) for acl in response.json()]
|
||||
|
||||
async def update_acl_rule(
|
||||
self,
|
||||
board_id: int,
|
||||
acl_id: int,
|
||||
permission_edit: Optional[bool] = None,
|
||||
permission_share: Optional[bool] = None,
|
||||
permission_manage: Optional[bool] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if permission_edit is not None:
|
||||
json_data["permissionEdit"] = permission_edit
|
||||
if permission_share is not None:
|
||||
json_data["permissionShare"] = permission_share
|
||||
if permission_manage is not None:
|
||||
json_data["permissionManage"] = permission_manage
|
||||
await self._make_request(
|
||||
"PUT", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}", json=json_data
|
||||
)
|
||||
|
||||
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}"
|
||||
)
|
||||
|
||||
async def clone_board(
|
||||
self,
|
||||
board_id: int,
|
||||
with_cards: bool = False,
|
||||
with_assignments: bool = False,
|
||||
with_labels: bool = False,
|
||||
with_due_date: bool = False,
|
||||
move_cards_to_left_stack: bool = False,
|
||||
restore_archived_cards: bool = False,
|
||||
) -> DeckBoard:
|
||||
json_data = {
|
||||
"withCards": with_cards,
|
||||
"withAssignments": with_assignments,
|
||||
"withLabels": with_labels,
|
||||
"withDueDate": with_due_date,
|
||||
"moveCardsToLeftStack": move_cards_to_left_stack,
|
||||
"restoreArchivedCards": restore_archived_cards,
|
||||
}
|
||||
response = await self._make_request(
|
||||
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/clone", json=json_data
|
||||
)
|
||||
return DeckBoard(**response.json())
|
||||
|
||||
# Stacks
|
||||
async def get_stacks(
|
||||
self, board_id: int, if_modified_since: Optional[str] = None
|
||||
) -> List[DeckStack]:
|
||||
additional_headers = {}
|
||||
if if_modified_since:
|
||||
additional_headers["If-Modified-Since"] = if_modified_since
|
||||
headers = self._get_deck_headers(additional_headers)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks", headers=headers
|
||||
)
|
||||
return [DeckStack(**stack) for stack in response.json()]
|
||||
|
||||
async def get_archived_stacks(self, board_id: int) -> List[DeckStack]:
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/archived"
|
||||
)
|
||||
return [DeckStack(**stack) for stack in response.json()]
|
||||
|
||||
async def get_stack(self, board_id: int, stack_id: int) -> DeckStack:
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
return DeckStack(**response.json())
|
||||
|
||||
async def create_stack(self, board_id: int, title: str, order: int) -> DeckStack:
|
||||
json_data = {"title": title, "order": order}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckStack(**response.json())
|
||||
|
||||
async def update_stack(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
title: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if order is not None:
|
||||
json_data["order"] = order
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_stack(self, board_id: int, stack_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
|
||||
# Cards
|
||||
async def get_card(self, board_id: int, stack_id: int, card_id: int) -> DeckCard:
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
headers=headers,
|
||||
)
|
||||
return DeckCard(**response.json())
|
||||
|
||||
async def create_card(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
title: str,
|
||||
type: str = "plain",
|
||||
order: int = 999,
|
||||
description: Optional[str] = None,
|
||||
duedate: Optional[str] = None,
|
||||
) -> DeckCard:
|
||||
json_data = {
|
||||
"title": title,
|
||||
"type": type,
|
||||
"order": order,
|
||||
}
|
||||
if description is not None:
|
||||
json_data["description"] = description
|
||||
if duedate is not None:
|
||||
json_data["duedate"] = duedate
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckCard(**response.json())
|
||||
|
||||
async def update_card(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
type: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
duedate: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
done: Optional[str] = None,
|
||||
) -> None:
|
||||
# First, get the current card to use existing values for required fields
|
||||
current_card = await self.get_card(board_id, stack_id, card_id)
|
||||
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if description is not None:
|
||||
json_data["description"] = description
|
||||
# Type is required by the API, use provided or keep current
|
||||
json_data["type"] = type if type is not None else current_card.type
|
||||
# Owner is required by the API, use provided or keep current
|
||||
json_data["owner"] = (
|
||||
owner
|
||||
if owner is not None
|
||||
else (
|
||||
current_card.owner
|
||||
if isinstance(current_card.owner, str)
|
||||
else current_card.owner.uid
|
||||
if hasattr(current_card.owner, "uid")
|
||||
else current_card.owner.primaryKey
|
||||
)
|
||||
)
|
||||
if order is not None:
|
||||
json_data["order"] = order
|
||||
if duedate is not None:
|
||||
json_data["duedate"] = duedate
|
||||
if archived is not None:
|
||||
json_data["archived"] = archived
|
||||
if done is not None:
|
||||
json_data["done"] = done
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_card(self, board_id: int, stack_id: int, card_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def archive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/archive",
|
||||
)
|
||||
|
||||
async def unarchive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unarchive",
|
||||
)
|
||||
|
||||
async def assign_label_to_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> None:
|
||||
json_data = {"labelId": label_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def remove_label_from_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> None:
|
||||
json_data = {"labelId": label_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/removeLabel",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def assign_user_to_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> None:
|
||||
json_data = {"userId": user_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignUser",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def unassign_user_from_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> None:
|
||||
json_data = {"userId": user_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unassignUser",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def reorder_card(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Labels
|
||||
async def get_label(self, board_id: int, label_id: int) -> DeckLabel:
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
|
||||
headers=headers,
|
||||
)
|
||||
return DeckLabel(**response.json())
|
||||
|
||||
async def create_label(self, board_id: int, title: str, color: str) -> DeckLabel:
|
||||
json_data = {"title": title, "color": color}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/labels",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckLabel(**response.json())
|
||||
|
||||
async def update_label(
|
||||
self,
|
||||
board_id: int,
|
||||
label_id: int,
|
||||
title: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if color is not None:
|
||||
json_data["color"] = color
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def delete_label(self, board_id: int, label_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}"
|
||||
)
|
||||
|
||||
# Attachments
|
||||
async def get_attachments(
|
||||
self, board_id: int, stack_id: int, card_id: int
|
||||
) -> List[DeckAttachment]:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
|
||||
)
|
||||
return [DeckAttachment(**attachment) for attachment in response.json()]
|
||||
|
||||
async def get_attachment_file(
|
||||
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
|
||||
) -> Any:
|
||||
# This endpoint returns the raw file, so we return the raw response content
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def upload_attachment(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
file_data: bytes,
|
||||
file_type: str = "file",
|
||||
) -> DeckAttachment:
|
||||
# The API expects binary data directly, not JSON
|
||||
headers = {"Content-Type": "application/octet-stream"}
|
||||
params = {"type": file_type}
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
return DeckAttachment(**response.json())
|
||||
|
||||
async def update_attachment(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
attachment_id: int,
|
||||
file_data: bytes,
|
||||
file_type: str = "deck_file",
|
||||
) -> DeckAttachment:
|
||||
headers = {"Content-Type": "application/octet-stream"}
|
||||
params = {"type": file_type}
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
return DeckAttachment(**response.json())
|
||||
|
||||
async def delete_attachment(
|
||||
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
|
||||
) -> None:
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
|
||||
)
|
||||
|
||||
async def restore_attachment(
|
||||
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
|
||||
) -> None:
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}/restore",
|
||||
)
|
||||
|
||||
# OCS API Endpoints (Config, Comments, Sessions)
|
||||
async def get_config(self) -> DeckConfig:
|
||||
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
||||
response = await self._make_request(
|
||||
"GET", "/ocs/v2.php/apps/deck/api/v1.0/config", headers=headers
|
||||
)
|
||||
return DeckConfig(**response.json()["ocs"]["data"])
|
||||
|
||||
async def set_config_value(
|
||||
self, key: str, value: Any, board_id: Optional[int] = None
|
||||
) -> Any:
|
||||
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/{key}"
|
||||
if board_id:
|
||||
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/board:{board_id}:{key}"
|
||||
json_data = {"value": value}
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
path,
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return response.json()["ocs"]["data"]
|
||||
|
||||
async def get_comments(
|
||||
self, card_id: int, limit: int = 20, offset: int = 0
|
||||
) -> List[DeckComment]:
|
||||
params = {"limit": limit, "offset": offset}
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return [DeckComment(**comment) for comment in response.json()["ocs"]["data"]]
|
||||
|
||||
async def create_comment(
|
||||
self, card_id: int, message: str, parent_id: Optional[int] = None
|
||||
) -> DeckComment:
|
||||
json_data = {"message": message}
|
||||
if parent_id is not None:
|
||||
json_data["parentId"] = parent_id
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return DeckComment(**response.json()["ocs"]["data"])
|
||||
|
||||
async def update_comment(
|
||||
self, card_id: int, comment_id: int, message: str
|
||||
) -> DeckComment:
|
||||
json_data = {"message": message}
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return DeckComment(**response.json()["ocs"]["data"])
|
||||
|
||||
async def delete_comment(self, card_id: int, comment_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def create_session(self, board_id: int) -> DeckSession:
|
||||
json_data = {"boardId": board_id}
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
"/ocs/v2.php/apps/deck/api/v1.0/session/create",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return DeckSession(**response.json()["ocs"]["data"])
|
||||
|
||||
async def sync_session(self, board_id: int, token: str) -> None:
|
||||
json_data = {"boardId": board_id, "token": token}
|
||||
await self._make_request(
|
||||
"POST",
|
||||
"/ocs/v2.php/apps/deck/api/v1.0/session/sync",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def close_session(self, board_id: int, token: str) -> None:
|
||||
json_data = {"boardId": board_id, "token": token}
|
||||
await self._make_request(
|
||||
"POST",
|
||||
"/ocs/v2.php/apps/deck/api/v1.0/session/close",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
@@ -415,3 +415,158 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating directory '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def move_resource(
|
||||
self, source_path: str, destination_path: str, overwrite: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Move or rename a resource (file or directory) via WebDAV MOVE.
|
||||
|
||||
Args:
|
||||
source_path: The path of the file or directory to move
|
||||
destination_path: The new path for the file or directory
|
||||
overwrite: Whether to overwrite the destination if it exists
|
||||
|
||||
Returns:
|
||||
Dict with status_code and optional message
|
||||
"""
|
||||
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
|
||||
destination_webdav_path = (
|
||||
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
|
||||
)
|
||||
|
||||
# Ensure paths have consistent trailing slashes for directories
|
||||
if source_path.endswith("/") and not destination_path.endswith("/"):
|
||||
destination_webdav_path += "/"
|
||||
elif not source_path.endswith("/") and destination_path.endswith("/"):
|
||||
source_webdav_path += "/"
|
||||
|
||||
logger.debug(f"Moving resource from '{source_path}' to '{destination_path}'")
|
||||
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
"Destination": destination_webdav_path,
|
||||
"Overwrite": "T" if overwrite else "F",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"MOVE", source_webdav_path, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(
|
||||
f"Successfully moved resource from '{source_path}' to '{destination_path}'"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Source resource '{source_path}' not found")
|
||||
return {"status_code": 404, "message": "Source resource not found"}
|
||||
elif e.response.status_code == 412:
|
||||
logger.debug(
|
||||
f"Destination '{destination_path}' already exists and overwrite is false"
|
||||
)
|
||||
return {
|
||||
"status_code": 412,
|
||||
"message": "Destination already exists and overwrite is false",
|
||||
}
|
||||
elif e.response.status_code == 409:
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def copy_resource(
|
||||
self, source_path: str, destination_path: str, overwrite: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Copy a resource (file or directory) via WebDAV COPY.
|
||||
|
||||
Args:
|
||||
source_path: The path of the file or directory to copy
|
||||
destination_path: The destination path for the copy
|
||||
overwrite: Whether to overwrite the destination if it exists
|
||||
|
||||
Returns:
|
||||
Dict with status_code and optional message
|
||||
"""
|
||||
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
|
||||
destination_webdav_path = (
|
||||
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
|
||||
)
|
||||
|
||||
# Ensure paths have consistent trailing slashes for directories
|
||||
if source_path.endswith("/") and not destination_path.endswith("/"):
|
||||
destination_webdav_path += "/"
|
||||
elif not source_path.endswith("/") and destination_path.endswith("/"):
|
||||
source_webdav_path += "/"
|
||||
|
||||
logger.debug(f"Copying resource from '{source_path}' to '{destination_path}'")
|
||||
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
"Destination": destination_webdav_path,
|
||||
"Overwrite": "T" if overwrite else "F",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"COPY", source_webdav_path, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(
|
||||
f"Successfully copied resource from '{source_path}' to '{destination_path}'"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Source resource '{source_path}' not found")
|
||||
return {"status_code": 404, "message": "Source resource not found"}
|
||||
elif e.response.status_code == 412:
|
||||
logger.debug(
|
||||
f"Destination '{destination_path}' already exists and overwrite is false"
|
||||
)
|
||||
return {
|
||||
"status_code": 412,
|
||||
"message": "Destination already exists and overwrite is false",
|
||||
}
|
||||
elif e.response.status_code == 409:
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# Base models
|
||||
from .base import (
|
||||
BaseResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
IdResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
@@ -82,8 +80,6 @@ from .webdav import (
|
||||
__all__ = [
|
||||
# Base models
|
||||
"BaseResponse",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
"IdResponse",
|
||||
"StatusResponse",
|
||||
# Notes models
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
"""Base Pydantic models for common response patterns."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Generate UTC timestamp for responses."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
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"
|
||||
default_factory=_utc_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")
|
||||
@field_serializer("timestamp")
|
||||
def serialize_timestamp(self, timestamp: datetime) -> str:
|
||||
"""Serialize timestamp to RFC3339 format for MCP compliance."""
|
||||
if timestamp.tzinfo is None:
|
||||
# If somehow we get a naive datetime, assume UTC
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
# Use isoformat() which produces RFC3339 compliant format
|
||||
# For UTC times, replace '+00:00' with 'Z' as preferred by many systems
|
||||
iso_string = timestamp.isoformat()
|
||||
if iso_string.endswith("+00:00"):
|
||||
return iso_string[:-6] + "Z"
|
||||
return iso_string
|
||||
|
||||
|
||||
class IdResponse(BaseResponse):
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class DeckUser(BaseModel):
|
||||
primaryKey: str
|
||||
uid: str
|
||||
displayname: str
|
||||
|
||||
|
||||
class DeckPermissions(BaseModel):
|
||||
PERMISSION_READ: bool
|
||||
PERMISSION_EDIT: bool
|
||||
PERMISSION_MANAGE: bool
|
||||
PERMISSION_SHARE: bool
|
||||
|
||||
|
||||
class DeckLabel(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
color: str
|
||||
boardId: Optional[int] = None
|
||||
cardId: Optional[int] = None
|
||||
|
||||
|
||||
class DeckACL(BaseModel):
|
||||
id: int
|
||||
participant: DeckUser
|
||||
type: int
|
||||
boardId: int
|
||||
permissionEdit: bool
|
||||
permissionShare: bool
|
||||
permissionManage: bool
|
||||
owner: bool
|
||||
|
||||
|
||||
class DeckBoardSettings(BaseModel):
|
||||
calendar: bool
|
||||
cardDetailsInModal: Optional[bool] = Field(default=None, alias="cardDetailsInModal")
|
||||
cardIdBadge: Optional[bool] = Field(default=None, alias="cardIdBadge")
|
||||
groupLimit: Optional[List[Dict[str, str]]] = Field(default=None, alias="groupLimit")
|
||||
notify_due: Optional[str] = Field(default=None, alias="notify-due")
|
||||
|
||||
|
||||
class DeckBoard(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
owner: DeckUser
|
||||
color: str
|
||||
archived: bool
|
||||
labels: List[DeckLabel]
|
||||
acl: List[DeckACL]
|
||||
permissions: DeckPermissions
|
||||
users: List[DeckUser]
|
||||
deletedAt: int
|
||||
lastModified: Optional[int] = None
|
||||
settings: Optional[DeckBoardSettings] = None
|
||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||
|
||||
@field_validator("settings", mode="before")
|
||||
@classmethod
|
||||
def validate_settings(cls, v):
|
||||
# Handle case where API returns empty array instead of dict/null
|
||||
if isinstance(v, list) and len(v) == 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
class DeckAssignedUser(BaseModel):
|
||||
id: int
|
||||
participant: DeckUser
|
||||
cardId: int
|
||||
type: int
|
||||
|
||||
|
||||
class DeckCard(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
stackId: int
|
||||
type: str
|
||||
order: int
|
||||
archived: bool
|
||||
owner: Union[str, DeckUser] # Can be either string or user object
|
||||
description: Optional[str] = None
|
||||
duedate: Optional[datetime] = None
|
||||
done: Optional[datetime] = None
|
||||
lastModified: Optional[int] = None
|
||||
createdAt: Optional[int] = None
|
||||
labels: Optional[List[DeckLabel]] = None
|
||||
assignedUsers: Optional[List[Union[DeckUser, DeckAssignedUser]]] = None
|
||||
attachments: Optional[List[Any]] = None # Define a proper Attachment model later
|
||||
attachmentCount: Optional[int] = None
|
||||
deletedAt: Optional[int] = None
|
||||
commentsUnread: Optional[int] = None
|
||||
overdue: Optional[int] = None
|
||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||
|
||||
@field_validator("owner", mode="before")
|
||||
@classmethod
|
||||
def validate_owner(cls, v):
|
||||
# Handle case where API returns user object instead of string
|
||||
if isinstance(v, dict):
|
||||
return v.get("uid", v.get("primaryKey", str(v)))
|
||||
return v
|
||||
|
||||
@field_validator("assignedUsers", mode="before")
|
||||
@classmethod
|
||||
def validate_assigned_users(cls, v):
|
||||
# Handle different formats of assigned users from the API
|
||||
if not v:
|
||||
return v
|
||||
|
||||
validated_users = []
|
||||
for user in v:
|
||||
if isinstance(user, dict):
|
||||
# Check if it's an assignment object with participant
|
||||
if "participant" in user:
|
||||
validated_users.append(user)
|
||||
# Check if it's a direct user object
|
||||
elif "uid" in user or "primaryKey" in user:
|
||||
validated_users.append(user)
|
||||
else:
|
||||
validated_users.append(user)
|
||||
|
||||
return validated_users
|
||||
|
||||
|
||||
class DeckStack(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
boardId: int
|
||||
order: int
|
||||
deletedAt: int
|
||||
lastModified: Optional[int] = None
|
||||
cards: Optional[List[DeckCard]] = None
|
||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||
|
||||
|
||||
class DeckAttachmentExtendedData(BaseModel):
|
||||
filesize: int
|
||||
mimetype: str
|
||||
info: Dict[str, str]
|
||||
|
||||
|
||||
class DeckAttachment(BaseModel):
|
||||
id: int
|
||||
cardId: int
|
||||
type: str
|
||||
data: str
|
||||
lastModified: int
|
||||
createdAt: int
|
||||
createdBy: str
|
||||
deletedAt: int
|
||||
extendedData: DeckAttachmentExtendedData
|
||||
|
||||
|
||||
class DeckComment(BaseModel):
|
||||
id: int
|
||||
objectId: int
|
||||
message: str
|
||||
actorId: str
|
||||
actorType: str
|
||||
actorDisplayName: str
|
||||
creationDateTime: datetime
|
||||
mentions: List[Dict[str, str]]
|
||||
replyTo: Optional[Any] = None # Self-referencing, handle later if needed
|
||||
|
||||
|
||||
class DeckSession(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class DeckConfig(BaseModel):
|
||||
calendar: bool
|
||||
cardDetailsInModal: bool
|
||||
cardIdBadge: bool
|
||||
groupLimit: Optional[List[Dict[str, str]]] = None
|
||||
|
||||
|
||||
# Response Models for MCP Tools
|
||||
|
||||
|
||||
class ListBoardsResponse(BaseResponse):
|
||||
"""Response model for listing deck boards."""
|
||||
|
||||
boards: List[DeckBoard] = Field(description="List of deck boards")
|
||||
total: int = Field(description="Total number of boards")
|
||||
|
||||
|
||||
class CreateBoardResponse(BaseResponse):
|
||||
"""Response model for board creation."""
|
||||
|
||||
id: int = Field(description="The created board ID")
|
||||
title: str = Field(description="The created board title")
|
||||
color: str = Field(description="The created board color")
|
||||
|
||||
|
||||
class BoardOperationResponse(StatusResponse):
|
||||
"""Response model for board operations like update/delete."""
|
||||
|
||||
board_id: int = Field(description="ID of the affected board")
|
||||
|
||||
|
||||
# Stack Response Models
|
||||
|
||||
|
||||
class ListStacksResponse(BaseResponse):
|
||||
"""Response model for listing deck stacks."""
|
||||
|
||||
stacks: List[DeckStack] = Field(description="List of deck stacks")
|
||||
total: int = Field(description="Total number of stacks")
|
||||
|
||||
|
||||
class CreateStackResponse(BaseResponse):
|
||||
"""Response model for stack creation."""
|
||||
|
||||
id: int = Field(description="The created stack ID")
|
||||
title: str = Field(description="The created stack title")
|
||||
order: int = Field(description="The created stack order")
|
||||
|
||||
|
||||
class StackOperationResponse(StatusResponse):
|
||||
"""Response model for stack operations like update/delete."""
|
||||
|
||||
stack_id: int = Field(description="ID of the affected stack")
|
||||
board_id: int = Field(description="ID of the board containing the stack")
|
||||
|
||||
|
||||
# Card Response Models
|
||||
|
||||
|
||||
class CreateCardResponse(BaseResponse):
|
||||
"""Response model for card creation."""
|
||||
|
||||
id: int = Field(description="The created card ID")
|
||||
title: str = Field(description="The created card title")
|
||||
description: Optional[str] = Field(description="The created card description")
|
||||
stackId: int = Field(description="The stack ID the card belongs to")
|
||||
|
||||
|
||||
class CardOperationResponse(StatusResponse):
|
||||
"""Response model for card operations like update/delete."""
|
||||
|
||||
card_id: int = Field(description="ID of the affected card")
|
||||
stack_id: int = Field(description="ID of the stack containing the card")
|
||||
board_id: int = Field(description="ID of the board containing the card")
|
||||
|
||||
|
||||
# Label Response Models
|
||||
|
||||
|
||||
class CreateLabelResponse(BaseResponse):
|
||||
"""Response model for label creation."""
|
||||
|
||||
id: int = Field(description="The created label ID")
|
||||
title: str = Field(description="The created label title")
|
||||
color: str = Field(description="The created label color")
|
||||
|
||||
|
||||
class LabelOperationResponse(StatusResponse):
|
||||
"""Response model for label operations like update/delete."""
|
||||
|
||||
label_id: int = Field(description="ID of the affected label")
|
||||
board_id: int = Field(description="ID of the board containing the label")
|
||||
@@ -19,7 +19,7 @@ class Note(BaseModel):
|
||||
favorite: bool = Field(
|
||||
default=False, description="Whether note is marked as favorite"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
etag: str = Field(description="ETag for versioning")
|
||||
readonly: bool = Field(default=False, description="Whether note is read-only")
|
||||
|
||||
@property
|
||||
@@ -48,13 +48,18 @@ class NotesSettings(BaseModel):
|
||||
class CreateNoteResponse(IdResponse):
|
||||
"""Response model for note creation."""
|
||||
|
||||
note: Note = Field(description="The created note")
|
||||
title: str = Field(description="The created note title")
|
||||
category: str = Field(description="The created note category")
|
||||
etag: str = Field(description="Current ETag for the created note")
|
||||
|
||||
|
||||
class UpdateNoteResponse(BaseResponse):
|
||||
"""Response model for note updates."""
|
||||
|
||||
note: Note = Field(description="The updated note")
|
||||
id: int = Field(description="The updated note ID")
|
||||
title: str = Field(description="The updated note title")
|
||||
category: str = Field(description="The updated note category")
|
||||
etag: str = Field(description="Current ETag for the updated note")
|
||||
|
||||
|
||||
class DeleteNoteResponse(StatusResponse):
|
||||
@@ -66,7 +71,10 @@ class DeleteNoteResponse(StatusResponse):
|
||||
class AppendContentResponse(BaseResponse):
|
||||
"""Response model for appending content to a note."""
|
||||
|
||||
note: Note = Field(description="The updated note after appending content")
|
||||
id: int = Field(description="The updated note ID")
|
||||
title: str = Field(description="The updated note title")
|
||||
category: str = Field(description="The updated note category")
|
||||
etag: str = Field(description="Current ETag for the updated note")
|
||||
|
||||
|
||||
class SearchNotesResponse(BaseResponse):
|
||||
|
||||
@@ -86,3 +86,23 @@ class DeleteResourceResponse(StatusResponse):
|
||||
items_deleted: Optional[int] = Field(
|
||||
None, description="Number of items deleted (for directories)"
|
||||
)
|
||||
|
||||
|
||||
class MoveResourceResponse(StatusResponse):
|
||||
"""Response model for resource move/rename operations."""
|
||||
|
||||
source_path: str = Field(description="Original path of the resource")
|
||||
destination_path: str = Field(description="New path of the resource")
|
||||
overwrite: bool = Field(
|
||||
description="Whether the destination was overwritten if it existed"
|
||||
)
|
||||
|
||||
|
||||
class CopyResourceResponse(StatusResponse):
|
||||
"""Response model for resource copy operations."""
|
||||
|
||||
source_path: str = Field(description="Original path of the resource")
|
||||
destination_path: str = Field(description="Destination path for the copy")
|
||||
overwrite: bool = Field(
|
||||
description="Whether the destination was overwritten if it existed"
|
||||
)
|
||||
|
||||
@@ -3,11 +3,13 @@ from .notes import configure_notes_tools
|
||||
from .tables import configure_tables_tools
|
||||
from .webdav import configure_webdav_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .deck import configure_deck_tools
|
||||
|
||||
__all__ = [
|
||||
"configure_calendar_tools",
|
||||
"configure_contacts_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_tables_tools",
|
||||
"configure_webdav_tools",
|
||||
"configure_contacts_tools",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
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.deck import (
|
||||
DeckBoard,
|
||||
DeckStack,
|
||||
DeckCard,
|
||||
DeckLabel,
|
||||
CreateBoardResponse,
|
||||
CreateStackResponse,
|
||||
StackOperationResponse,
|
||||
CreateCardResponse,
|
||||
CardOperationResponse,
|
||||
CreateLabelResponse,
|
||||
LabelOperationResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_deck_tools(mcp: FastMCP):
|
||||
"""Configure Nextcloud Deck tools and resources for the MCP server."""
|
||||
|
||||
# Resources
|
||||
@mcp.resource("nc://Deck/boards")
|
||||
async def deck_boards_resource():
|
||||
"""List all Nextcloud Deck boards"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning("This message is deprecated, use the deck_get_board instead")
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
boards = await client.deck.get_boards()
|
||||
return [board.model_dump() for board in boards]
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}")
|
||||
async def deck_board_resource(board_id: int):
|
||||
"""Get details of a specific Nextcloud Deck board"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_board tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.model_dump()
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/stacks")
|
||||
async def deck_stacks_resource(board_id: int):
|
||||
"""List all stacks in a Nextcloud Deck board"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_stacks tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return [stack.model_dump() for stack in stacks]
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}")
|
||||
async def deck_stack_resource(board_id: int, stack_id: int):
|
||||
"""Get details of a specific Nextcloud Deck stack"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_stack tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack.model_dump()
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards")
|
||||
async def deck_cards_resource(board_id: int, stack_id: int):
|
||||
"""List all cards in a Nextcloud Deck stack"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_cards tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return [card.model_dump() for card in stack.cards]
|
||||
return []
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}")
|
||||
async def deck_card_resource(board_id: int, stack_id: int, card_id: int):
|
||||
"""Get details of a specific Nextcloud Deck card"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_card tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card.model_dump()
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/labels")
|
||||
async def deck_labels_resource(board_id: int):
|
||||
"""List all labels in a Nextcloud Deck board"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_labels tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
board = await client.deck.get_board(board_id)
|
||||
return [label.model_dump() for label in board.labels]
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||
async def deck_label_resource(board_id: int, label_id: int):
|
||||
"""Get details of a specific Nextcloud Deck label"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_label tool instead"
|
||||
)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
label = await client.deck.get_label(board_id, label_id)
|
||||
return label.model_dump()
|
||||
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||
"""Get details of a specific Nextcloud Deck board"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||
"""Get details of a specific Nextcloud Deck stack"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
"""Get all cards in a Nextcloud Deck stack"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return stack.cards
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
"""Get details of a specific Nextcloud Deck card"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||
"""Get details of a specific Nextcloud Deck label"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
label = await client.deck.get_label(board_id, label_id)
|
||||
return label
|
||||
|
||||
# Create/Update/Delete Tools
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_create_board(
|
||||
ctx: Context, title: str, color: str
|
||||
) -> CreateBoardResponse:
|
||||
"""Create a new Nextcloud Deck board
|
||||
|
||||
Args:
|
||||
title: The title of the new board
|
||||
color: The hexadecimal color of the new board (e.g. FF0000)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
board = await client.deck.create_board(title, color)
|
||||
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
|
||||
|
||||
# Stack Tools
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_create_stack(
|
||||
ctx: Context, board_id: int, title: str, order: int
|
||||
) -> CreateStackResponse:
|
||||
"""Create a new stack in a Nextcloud Deck board
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
title: The title of the new stack
|
||||
order: Order for sorting the stacks
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
stack = await client.deck.create_stack(board_id, title, order)
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_update_stack(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
title: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
) -> StackOperationResponse:
|
||||
"""Update a Nextcloud Deck stack
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
title: New title for the stack
|
||||
order: New order for the stack
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.update_stack(board_id, stack_id, title, order)
|
||||
return StackOperationResponse(
|
||||
success=True,
|
||||
message="Stack updated successfully",
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_delete_stack(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> StackOperationResponse:
|
||||
"""Delete a Nextcloud Deck stack
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.delete_stack(board_id, stack_id)
|
||||
return StackOperationResponse(
|
||||
success=True,
|
||||
message="Stack deleted successfully",
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
# Card Tools
|
||||
@mcp.tool()
|
||||
async def deck_create_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
title: str,
|
||||
type: str = "plain",
|
||||
order: int = 999,
|
||||
description: Optional[str] = None,
|
||||
duedate: Optional[str] = None,
|
||||
) -> CreateCardResponse:
|
||||
"""Create a new card in a Nextcloud Deck stack
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
title: The title of the new card
|
||||
type: Type of the card (default: plain)
|
||||
order: Order for sorting the cards
|
||||
description: Description of the card
|
||||
duedate: Due date of the card (ISO-8601 format)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
card = await client.deck.create_card(
|
||||
board_id, stack_id, title, type, order, description, duedate
|
||||
)
|
||||
return CreateCardResponse(
|
||||
id=card.id,
|
||||
title=card.title,
|
||||
description=card.description,
|
||||
stackId=card.stackId,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_update_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
type: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
duedate: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
done: Optional[str] = None,
|
||||
) -> CardOperationResponse:
|
||||
"""Update a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
title: New title for the card
|
||||
description: New description for the card
|
||||
type: New type for the card
|
||||
owner: New owner for the card
|
||||
order: New order for the card
|
||||
duedate: New due date for the card (ISO-8601 format)
|
||||
archived: Whether the card should be archived
|
||||
done: Completion date for the card (ISO-8601 format)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
card_id,
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
owner,
|
||||
order,
|
||||
duedate,
|
||||
archived,
|
||||
done,
|
||||
)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Card updated successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_delete_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
"""Delete a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.delete_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Card deleted successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_archive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
"""Archive a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.archive_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Card archived successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_unarchive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
"""Unarchive a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Card unarchived successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_reorder_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> CardOperationResponse:
|
||||
"""Reorder/move a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the current stack
|
||||
card_id: The ID of the card
|
||||
order: New position in the target stack
|
||||
target_stack_id: The ID of the target stack
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.reorder_card(
|
||||
board_id, stack_id, card_id, order, target_stack_id
|
||||
)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Card reordered successfully",
|
||||
card_id=card_id,
|
||||
stack_id=target_stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
# Label Tools
|
||||
@mcp.tool()
|
||||
async def deck_create_label(
|
||||
ctx: Context, board_id: int, title: str, color: str
|
||||
) -> CreateLabelResponse:
|
||||
"""Create a new label in a Nextcloud Deck board
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
title: The title of the new label
|
||||
color: The color of the new label (hex format without #)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
label = await client.deck.create_label(board_id, title, color)
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_update_label(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
label_id: int,
|
||||
title: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> LabelOperationResponse:
|
||||
"""Update a Nextcloud Deck label
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
label_id: The ID of the label
|
||||
title: New title for the label
|
||||
color: New color for the label (hex format without #)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.update_label(board_id, label_id, title, color)
|
||||
return LabelOperationResponse(
|
||||
success=True,
|
||||
message="Label updated successfully",
|
||||
label_id=label_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_delete_label(
|
||||
ctx: Context, board_id: int, label_id: int
|
||||
) -> LabelOperationResponse:
|
||||
"""Delete a Nextcloud Deck label
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
label_id: The ID of the label
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.delete_label(board_id, label_id)
|
||||
return LabelOperationResponse(
|
||||
success=True,
|
||||
message="Label deleted successfully",
|
||||
label_id=label_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
# Card-Label Assignment Tools
|
||||
@mcp.tool()
|
||||
async def deck_assign_label_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
"""Assign a label to a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
label_id: The ID of the label to assign
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Label assigned to card successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_remove_label_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
"""Remove a label from a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
label_id: The ID of the label to remove
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="Label removed from card successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
# Card-User Assignment Tools
|
||||
@mcp.tool()
|
||||
async def deck_assign_user_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
"""Assign a user to a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
user_id: The user ID to assign
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="User assigned to card successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def deck_unassign_user_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
"""Unassign a user from a Nextcloud Deck card
|
||||
|
||||
Args:
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
user_id: The user ID to unassign
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
message="User unassigned from card successfully",
|
||||
card_id=card_id,
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
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,
|
||||
@@ -31,7 +32,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
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):
|
||||
async def nc_notes_get_attachment_resource(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
|
||||
@@ -52,10 +53,8 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}")
|
||||
async def nc_get_note(note_id: int):
|
||||
async def nc_get_note_resource(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
|
||||
@@ -80,7 +79,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse | ErrorResponse:
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
@@ -90,21 +89,32 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category=category,
|
||||
)
|
||||
note = Note(**note_data)
|
||||
return CreateNoteResponse(id=note.id, note=note)
|
||||
return CreateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error="Access denied: insufficient permissions to create notes"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to create notes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 413:
|
||||
return ErrorResponse(error="Note content too large")
|
||||
raise McpError(ErrorData(code=-1, message="Note content too large"))
|
||||
elif e.response.status_code == 409:
|
||||
return ErrorResponse(
|
||||
error=f"A note with title '{title}' already exists in this category"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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})"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to create note: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -115,8 +125,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
content: str | None,
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse | ErrorResponse:
|
||||
"""Update an existing note's title, content, or category"""
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
|
||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||
If the note has been modified by someone else since you retrieved it,
|
||||
the update will fail with a 412 error."""
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
@@ -128,30 +143,45 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category=category,
|
||||
)
|
||||
note = Note(**note_data)
|
||||
return UpdateNoteResponse(note=note)
|
||||
return UpdateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return ErrorResponse(error=f"Note {note_id} not found")
|
||||
raise McpError(ErrorData(code=-1, message=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."
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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}"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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")
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message="Updated note content is too large")
|
||||
)
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Failed to update note {note_id}: server error ({e.response.status_code})"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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"""
|
||||
) -> AppendContentResponse:
|
||||
"""Append content to an existing note. The tool adds a `\n---\n`
|
||||
between the note and what will be appended."""
|
||||
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
@@ -159,27 +189,36 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
note_id=note_id, content=content
|
||||
)
|
||||
note = Note(**note_data)
|
||||
return AppendContentResponse(note=note)
|
||||
return AppendContentResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return ErrorResponse(error=f"Note {note_id} not found")
|
||||
raise McpError(ErrorData(code=-1, message=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}"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="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})"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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:
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
@@ -201,20 +240,86 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
return ErrorResponse(
|
||||
error="Access denied: insufficient permissions to search notes"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to search notes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 400:
|
||||
return ErrorResponse(error="Invalid search query format")
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message="Invalid search query format")
|
||||
)
|
||||
else:
|
||||
return ErrorResponse(
|
||||
error=f"Search failed: server error ({e.response.status_code})"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Search failed: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_delete_note(
|
||||
note_id: int, ctx: Context
|
||||
) -> DeleteNoteResponse | ErrorResponse:
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
"""Get a specific note by its ID"""
|
||||
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_get_attachment(
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
"""Get a specific attachment from a note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
try:
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
|
||||
"mimeType": mime_type,
|
||||
"data": content,
|
||||
}
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Attachment {attachment_filename} not found for note {note_id}",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Access denied to attachment {attachment_filename} for note {note_id}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve attachment: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
@@ -227,12 +332,18 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return ErrorResponse(error=f"Note {note_id} not found")
|
||||
raise McpError(ErrorData(code=-1, message=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}"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=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})"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to delete note {note_id}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -43,11 +43,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
print(result['content']) # Decoded text content
|
||||
logger.info(result['content']) # Decoded text content
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
print(result['encoding']) # 'base64'
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
@@ -149,3 +149,67 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_move_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
"""Move or rename a file or directory in NextCloud.
|
||||
|
||||
Args:
|
||||
source_path: Full path of the file or directory to move
|
||||
destination_path: New path for the file or directory
|
||||
overwrite: Whether to overwrite the destination if it exists (default: False)
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
|
||||
Examples:
|
||||
# Rename a file
|
||||
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
||||
|
||||
# Move a file to another directory
|
||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
||||
|
||||
# Move a directory
|
||||
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
||||
|
||||
# Move and overwrite if destination exists
|
||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.move_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_copy_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
"""Copy a file or directory in NextCloud.
|
||||
|
||||
Args:
|
||||
source_path: Full path of the file or directory to copy
|
||||
destination_path: Destination path for the copy
|
||||
overwrite: Whether to overwrite the destination if it exists (default: False)
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
|
||||
Examples:
|
||||
# Copy a file
|
||||
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
||||
|
||||
# Copy a file to another directory
|
||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
||||
|
||||
# Copy a directory
|
||||
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
||||
|
||||
# Copy and overwrite if destination exists
|
||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.copy_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.7.2"
|
||||
version = "0.12.4"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
@@ -8,12 +8,13 @@ authors = [
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.10,<1.11)",
|
||||
"mcp[cli] (>=1.15,<1.16)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -48,3 +49,6 @@ dev = [
|
||||
"pytest-cov>=6.1.1",
|
||||
"ruff>=0.11.13",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
|
||||
|
||||
+156
-10
@@ -6,7 +6,7 @@ from typing import Any, AsyncGenerator
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -39,18 +39,18 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests.
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
"""
|
||||
logger.info("Creating SSE client")
|
||||
sse_context = sse_client(url="http://127.0.0.1:8000/sse")
|
||||
logger.info("Creating Streamable HTTP client")
|
||||
streamable_context = streamablehttp_client("http://127.0.0.1:8000/mcp")
|
||||
session_context = None
|
||||
|
||||
try:
|
||||
read, write = await sse_context.__aenter__()
|
||||
session_context = ClientSession(read, write)
|
||||
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||
session_context = ClientSession(read_stream, write_stream)
|
||||
session = await session_context.__aenter__()
|
||||
await session.initialize()
|
||||
logger.info("MCP client session initialized successfully")
|
||||
@@ -71,14 +71,14 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
logger.warning(f"Error closing session: {e}")
|
||||
|
||||
try:
|
||||
await sse_context.__aexit__(None, None, None)
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e):
|
||||
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||
else:
|
||||
logger.warning(f"Error closing SSE client: {e}")
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing SSE client: {e}")
|
||||
logger.warning(f"Error closing streamable HTTP client: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -250,3 +250,149 @@ async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: s
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary contact {contact_uid}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_board(nc_client: NextcloudClient):
|
||||
"""
|
||||
Fixture to create a temporary deck board for tests and ensure its deletion afterward.
|
||||
Yields the created board data dict.
|
||||
"""
|
||||
board_id = None
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
board_title = f"Temporary Test Board {unique_suffix}"
|
||||
board_color = "FF0000" # Red color
|
||||
created_board_data = None
|
||||
|
||||
logger.info(f"Creating temporary deck board: {board_title}")
|
||||
try:
|
||||
created_board = await nc_client.deck.create_board(board_title, board_color)
|
||||
board_id = created_board.id
|
||||
created_board_data = {
|
||||
"id": board_id,
|
||||
"title": created_board.title,
|
||||
"color": created_board.color,
|
||||
"archived": getattr(created_board, "archived", False),
|
||||
}
|
||||
|
||||
logger.info(f"Temporary board created with ID: {board_id}")
|
||||
yield created_board_data
|
||||
|
||||
finally:
|
||||
if board_id:
|
||||
logger.info(f"Cleaning up temporary board ID: {board_id}")
|
||||
try:
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
logger.info(f"Successfully deleted temporary board ID: {board_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if board was already deleted by the test itself
|
||||
if e.response.status_code not in [404, 403]:
|
||||
logger.error(f"HTTP error deleting temporary board {board_id}: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary board {board_id} already deleted or access denied ({e.response.status_code})."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary board {board_id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_board_with_stack(nc_client: NextcloudClient, temporary_board: dict):
|
||||
"""
|
||||
Fixture to create a temporary stack in a temporary board.
|
||||
Yields a tuple: (board_data, stack_data).
|
||||
Depends on the temporary_board fixture.
|
||||
"""
|
||||
board_data = temporary_board
|
||||
board_id = board_data["id"]
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
stack_title = f"Test Stack {unique_suffix}"
|
||||
stack_order = 1
|
||||
stack = None
|
||||
|
||||
logger.info(f"Creating temporary stack in board ID: {board_id}")
|
||||
try:
|
||||
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
||||
stack_data = {
|
||||
"id": stack.id,
|
||||
"title": stack.title,
|
||||
"order": stack.order,
|
||||
"boardId": board_id,
|
||||
}
|
||||
|
||||
logger.info(f"Temporary stack created with ID: {stack.id}")
|
||||
yield (board_data, stack_data)
|
||||
|
||||
finally:
|
||||
# Clean up - delete stack
|
||||
if stack and hasattr(stack, "id"):
|
||||
logger.info(f"Cleaning up temporary stack ID: {stack.id}")
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, stack.id)
|
||||
logger.info(f"Successfully deleted temporary stack ID: {stack.id}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code not in [404, 403]:
|
||||
logger.error(f"HTTP error deleting temporary stack {stack.id}: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary stack {stack.id} already deleted or access denied ({e.response.status_code})."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary stack {stack.id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_board_with_card(
|
||||
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary card in a temporary stack within a temporary board.
|
||||
Yields a tuple: (board_data, stack_data, card_data).
|
||||
Depends on the temporary_board_with_stack fixture.
|
||||
"""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card_title = f"Test Card {unique_suffix}"
|
||||
card_description = f"Test description for card {unique_suffix}"
|
||||
card = None
|
||||
|
||||
logger.info(
|
||||
f"Creating temporary card in stack ID: {stack_id}, board ID: {board_id}"
|
||||
)
|
||||
try:
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, stack_id, card_title, description=card_description
|
||||
)
|
||||
card_data = {
|
||||
"id": card.id,
|
||||
"title": card.title,
|
||||
"description": card.description,
|
||||
"stackId": stack_id,
|
||||
"boardId": board_id,
|
||||
}
|
||||
|
||||
logger.info(f"Temporary card created with ID: {card.id}")
|
||||
yield (board_data, stack_data, card_data)
|
||||
|
||||
finally:
|
||||
# Clean up - delete card
|
||||
if card and hasattr(card, "id"):
|
||||
logger.info(f"Cleaning up temporary card ID: {card.id}")
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
||||
logger.info(f"Successfully deleted temporary card ID: {card.id}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code not in [404, 403]:
|
||||
logger.error(f"HTTP error deleting temporary card {card.id}: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Temporary card {card.id} already deleted or access denied ({e.response.status_code})."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckStack, DeckCard, DeckLabel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# Board CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete board CRUD workflow using the temporary_board fixture.
|
||||
"""
|
||||
board_data = temporary_board
|
||||
board_id = board_data["id"]
|
||||
original_title = board_data["title"]
|
||||
original_color = board_data["color"]
|
||||
|
||||
logger.info(f"Testing CRUD operations on board ID: {board_id}")
|
||||
|
||||
# Read the board
|
||||
read_board = await nc_client.deck.get_board(board_id)
|
||||
assert read_board.id == board_id
|
||||
assert read_board.title == original_title
|
||||
assert read_board.color == original_color
|
||||
logger.info(f"Successfully read board ID: {board_id}")
|
||||
|
||||
# Update the board
|
||||
updated_title = f"Updated {original_title}"
|
||||
updated_color = "00FF00" # Green color
|
||||
await nc_client.deck.update_board(
|
||||
board_id, title=updated_title, color=updated_color
|
||||
)
|
||||
|
||||
# Verify the update
|
||||
updated_board = await nc_client.deck.get_board(board_id)
|
||||
assert updated_board.title == updated_title
|
||||
assert updated_board.color == updated_color
|
||||
logger.info(f"Successfully updated board ID: {board_id}")
|
||||
|
||||
|
||||
async def test_deck_list_boards(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all boards with different options.
|
||||
"""
|
||||
# Test basic listing
|
||||
boards = await nc_client.deck.get_boards()
|
||||
assert isinstance(boards, list)
|
||||
logger.info(f"Found {len(boards)} boards")
|
||||
|
||||
# Test with details
|
||||
detailed_boards = await nc_client.deck.get_boards(details=True)
|
||||
assert isinstance(detailed_boards, list)
|
||||
logger.info(f"Found {len(detailed_boards)} boards with details")
|
||||
|
||||
|
||||
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test operations on non-existent board return appropriate errors.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test get non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.get_board(non_existent_id)
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
] # 403 might be returned for access denied
|
||||
logger.info(
|
||||
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
)
|
||||
|
||||
# Test update non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
400,
|
||||
] # 400 for bad request on invalid board ID
|
||||
logger.info(
|
||||
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# Stack CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_stack_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete stack CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
|
||||
stack_order = 1
|
||||
stack = None
|
||||
|
||||
try:
|
||||
# Create stack
|
||||
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.title == stack_title
|
||||
assert stack.order == stack_order
|
||||
stack_id = stack.id
|
||||
logger.info(f"Created stack ID: {stack_id}")
|
||||
|
||||
# Read stack
|
||||
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert read_stack.id == stack_id
|
||||
assert read_stack.title == stack_title
|
||||
logger.info(f"Successfully read stack ID: {stack_id}")
|
||||
|
||||
# Update stack
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
await nc_client.deck.update_stack(
|
||||
board_id, stack_id, title=updated_title, order=updated_order
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert updated_stack.title == updated_title
|
||||
assert updated_stack.order == updated_order
|
||||
logger.info(f"Successfully updated stack ID: {stack_id}")
|
||||
|
||||
# List stacks
|
||||
stacks = await nc_client.deck.get_stacks(board_id)
|
||||
assert isinstance(stacks, list)
|
||||
assert any(s.id == stack_id for s in stacks)
|
||||
logger.info(f"Found stack ID: {stack_id} in board stacks list")
|
||||
|
||||
finally:
|
||||
# Clean up - delete stack
|
||||
if stack and hasattr(stack, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, stack.id)
|
||||
logger.info(f"Cleaned up stack ID: {stack.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
|
||||
|
||||
|
||||
# Card CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_card_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
||||
):
|
||||
"""
|
||||
Test complete card CRUD workflow.
|
||||
"""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
|
||||
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
|
||||
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
|
||||
card = None
|
||||
|
||||
try:
|
||||
# Create card
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, stack_id, card_title, description=card_description
|
||||
)
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.title == card_title
|
||||
assert card.description == card_description
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id}")
|
||||
|
||||
# Read card
|
||||
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert read_card.id == card_id
|
||||
assert read_card.title == card_title
|
||||
logger.info(f"Successfully read card ID: {card_id}")
|
||||
|
||||
# Update card
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
await nc_client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
card_id,
|
||||
title=updated_title,
|
||||
description=updated_description,
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert updated_card.title == updated_title
|
||||
assert updated_card.description == updated_description
|
||||
logger.info(f"Successfully updated card ID: {card_id}")
|
||||
|
||||
# Archive and unarchive card
|
||||
await nc_client.deck.archive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Archived card ID: {card_id}")
|
||||
|
||||
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Unarchived card ID: {card_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete card
|
||||
if card and hasattr(card, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
||||
logger.info(f"Cleaned up card ID: {card.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
|
||||
|
||||
|
||||
# Label CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_label_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete label CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
|
||||
label_color = "FF0000" # Red
|
||||
label = None
|
||||
|
||||
try:
|
||||
# Create label
|
||||
label = await nc_client.deck.create_label(board_id, label_title, label_color)
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.title == label_title
|
||||
assert label.color == label_color
|
||||
label_id = label.id
|
||||
logger.info(f"Created label ID: {label_id}")
|
||||
|
||||
# Read label
|
||||
read_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert read_label.id == label_id
|
||||
assert read_label.title == label_title
|
||||
logger.info(f"Successfully read label ID: {label_id}")
|
||||
|
||||
# Update label
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
await nc_client.deck.update_label(
|
||||
board_id, label_id, title=updated_title, color=updated_color
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert updated_label.title == updated_title
|
||||
assert updated_label.color == updated_color
|
||||
logger.info(f"Successfully updated label ID: {label_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete label
|
||||
if label and hasattr(label, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_label(board_id, label.id)
|
||||
logger.info(f"Cleaned up label ID: {label.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
|
||||
|
||||
|
||||
# Configuration and Comments Tests
|
||||
|
||||
|
||||
async def test_deck_config_operations(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test deck configuration operations.
|
||||
"""
|
||||
# Get config
|
||||
config = await nc_client.deck.get_config()
|
||||
assert config is not None
|
||||
logger.info(f"Retrieved deck config: {config}")
|
||||
|
||||
|
||||
async def test_deck_comments_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_card: tuple
|
||||
):
|
||||
"""
|
||||
Test comment operations on a card.
|
||||
"""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
|
||||
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
|
||||
comment = None
|
||||
|
||||
try:
|
||||
# Create comment
|
||||
comment = await nc_client.deck.create_comment(card_id, comment_message)
|
||||
assert comment.message == comment_message
|
||||
comment_id = comment.id
|
||||
logger.info(f"Created comment ID: {comment_id}")
|
||||
|
||||
# List comments
|
||||
comments = await nc_client.deck.get_comments(card_id)
|
||||
assert isinstance(comments, list)
|
||||
assert any(c.id == comment_id for c in comments)
|
||||
logger.info(f"Found comment ID: {comment_id} in card comments")
|
||||
|
||||
# Update comment
|
||||
updated_message = f"Updated {comment_message}"
|
||||
updated_comment = await nc_client.deck.update_comment(
|
||||
card_id, comment_id, updated_message
|
||||
)
|
||||
assert updated_comment.message == updated_message
|
||||
logger.info(f"Successfully updated comment ID: {comment_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete comment
|
||||
if comment and hasattr(comment, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_comment(card_id, comment.id)
|
||||
logger.info(f"Cleaned up comment ID: {comment.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
|
||||
@@ -0,0 +1,223 @@
|
||||
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_deck_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"""Test deck MCP tools are available and functional."""
|
||||
|
||||
# List available tools
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
tool_names = [tool.name for tool in tools.tools]
|
||||
|
||||
# Verify expected deck tools are present
|
||||
expected_deck_tools = ["deck_create_board"]
|
||||
|
||||
for expected_tool in expected_deck_tools:
|
||||
assert expected_tool in tool_names, (
|
||||
f"Expected deck tool '{expected_tool}' not found in available tools"
|
||||
)
|
||||
logger.info(f"Found expected deck tool: {expected_tool}")
|
||||
|
||||
# List available resource templates
|
||||
templates = await nc_mcp_client.list_resource_templates()
|
||||
template_uris = [template.uriTemplate for template in templates.resourceTemplates]
|
||||
|
||||
# Verify expected deck resource templates
|
||||
expected_deck_templates = [
|
||||
"nc://Deck/boards/{board_id}",
|
||||
]
|
||||
|
||||
for expected_template in expected_deck_templates:
|
||||
assert expected_template in template_uris, (
|
||||
f"Expected deck template '{expected_template}' not found"
|
||||
)
|
||||
logger.info(f"Found expected deck resource template: {expected_template}")
|
||||
|
||||
# List available resources
|
||||
resources = await nc_mcp_client.list_resources()
|
||||
resource_uris = [str(resource.uri) for resource in resources.resources]
|
||||
|
||||
# Verify expected deck resources
|
||||
expected_deck_resources = [
|
||||
"nc://Deck/boards",
|
||||
]
|
||||
|
||||
for expected_resource in expected_deck_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
f"Expected deck resource '{expected_resource}' not found"
|
||||
)
|
||||
logger.info(f"Found expected deck resource: {expected_resource}")
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow_mcp(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test complete Deck board CRUD workflow via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
board_title = f"MCP Test Board {unique_suffix}"
|
||||
board_color = "0000FF" # Blue
|
||||
|
||||
# 1. Create board via MCP
|
||||
logger.info(f"Creating board via MCP: {board_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_board",
|
||||
{"title": board_title, "color": board_color},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP board creation failed: {create_result.content}"
|
||||
)
|
||||
created_board_json = create_result.content[0].text
|
||||
created_board_response = json.loads(created_board_json)
|
||||
board_id = created_board_response["id"]
|
||||
|
||||
logger.info(f"Board created via MCP with ID: {board_id}")
|
||||
assert created_board_response["title"] == board_title
|
||||
assert created_board_response["color"] == board_color
|
||||
|
||||
# 2. Verify creation via direct NextcloudClient
|
||||
direct_board = await nc_client.deck.get_board(board_id)
|
||||
assert direct_board.title == board_title, (
|
||||
f"Title mismatch: {direct_board.title} != {board_title}"
|
||||
)
|
||||
assert direct_board.color == board_color, "Color mismatch"
|
||||
logger.info("Board creation verified via direct client")
|
||||
|
||||
# 3. Read board via MCP resource
|
||||
logger.info(f"Reading board via MCP resource: {board_id}")
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}")
|
||||
assert len(read_result.contents) == 1, "Expected exactly one content item"
|
||||
read_board_data = json.loads(read_result.contents[0].text)
|
||||
|
||||
assert read_board_data["title"] == board_title
|
||||
assert read_board_data["color"] == board_color
|
||||
logger.info("Board read via MCP resource successfully")
|
||||
|
||||
# 4. Verify board via direct read of resource
|
||||
logger.info(f"Verifying board via resource read: {board_id}")
|
||||
# This was already done in step 3, so we'll just log confirmation
|
||||
logger.info("Board structure verified successfully")
|
||||
|
||||
# 5. Read boards list via MCP resource
|
||||
logger.info("Reading boards list via MCP resource")
|
||||
boards_resource_result = await nc_mcp_client.read_resource("nc://Deck/boards")
|
||||
assert len(boards_resource_result.contents) == 1, (
|
||||
"Expected exactly one content item"
|
||||
)
|
||||
boards_resource_data = json.loads(boards_resource_result.contents[0].text)
|
||||
assert isinstance(boards_resource_data, list) # Resources return raw lists
|
||||
|
||||
# Verify our board is in the resource list
|
||||
resource_board_ids = [board["id"] for board in boards_resource_data]
|
||||
assert board_id in resource_board_ids, "Created board not found in resource list"
|
||||
logger.info("Board found in boards resource list")
|
||||
|
||||
# Clean up - delete board
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
logger.info(f"Cleaned up board ID: {board_id}")
|
||||
|
||||
|
||||
async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSession):
|
||||
"""Test MCP deck tools handle errors appropriately."""
|
||||
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test create board with invalid parameters via MCP tool
|
||||
logger.info("Testing board creation with invalid parameters via MCP")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_board",
|
||||
{"title": "", "color": "FF0000"},
|
||||
)
|
||||
|
||||
assert create_result.isError is True, "Expected error for invalid board creation"
|
||||
logger.info("Invalid board creation correctly failed via MCP tool")
|
||||
|
||||
# Test read non-existent board via MCP resource
|
||||
logger.info(f"Testing read non-existent board via MCP resource: {non_existent_id}")
|
||||
try:
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{non_existent_id}"
|
||||
)
|
||||
# If no error is thrown, check if the result indicates an error
|
||||
assert len(read_result.contents) == 0, (
|
||||
"Expected empty content for non-existent board"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"Read non-existent board correctly failed via MCP resource: {e}")
|
||||
|
||||
|
||||
async def test_deck_board_creation_validation_mcp(nc_mcp_client: ClientSession):
|
||||
"""Test deck board creation validation via MCP tools."""
|
||||
|
||||
# Test creating board with empty title should fail
|
||||
logger.info("Testing board creation with empty title via MCP")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_board",
|
||||
{"title": "", "color": "FF0000"},
|
||||
)
|
||||
|
||||
assert create_result.isError is True, "Expected error for empty board title"
|
||||
logger.info("Empty title board creation correctly failed via MCP")
|
||||
|
||||
|
||||
async def test_deck_board_creation_success_mcp(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test deck board creation with valid parameters via MCP tools."""
|
||||
|
||||
# Test creating board with valid parameters
|
||||
logger.info("Testing board creation with valid parameters via MCP")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_board",
|
||||
{"title": f"Valid Board {uuid.uuid4().hex[:8]}", "color": "00FF00"},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, "Valid board creation should succeed"
|
||||
created_board = json.loads(create_result.content[0].text)
|
||||
board_id = created_board["id"]
|
||||
logger.info(f"Valid board created successfully with ID: {board_id}")
|
||||
|
||||
# Clean up - delete board
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
logger.info(f"Cleaned up board ID: {board_id}")
|
||||
|
||||
|
||||
async def test_deck_workflow_integration_mcp(
|
||||
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||
):
|
||||
"""Test a complete deck workflow using MCP tools with temporary resources."""
|
||||
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
board_id = board_data["id"]
|
||||
board_title = board_data["title"]
|
||||
|
||||
# 1. Read board via MCP to verify the structure
|
||||
logger.info(f"Reading board via MCP resource: {board_id}")
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}")
|
||||
board_mcp_data = json.loads(read_result.contents[0].text)
|
||||
|
||||
assert board_mcp_data["title"] == board_title
|
||||
logger.info("Board structure verified via MCP resource")
|
||||
|
||||
# 2. List boards via MCP resource and verify our board is there
|
||||
logger.info("Listing boards via MCP resource")
|
||||
list_result = await nc_mcp_client.read_resource("nc://Deck/boards")
|
||||
boards_data = json.loads(list_result.contents[0].text)
|
||||
|
||||
board_found = any(board["id"] == board_id for board in boards_data)
|
||||
assert board_found, "Board not found in boards list"
|
||||
logger.info("Board found in boards list")
|
||||
|
||||
# 3. Verify board data matches via resource (already done in step 1)
|
||||
logger.info(f"Board data verification completed for board: {board_id}")
|
||||
logger.info("Board structure and data verified successfully")
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
from mcp import ClientSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -10,33 +9,29 @@ 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")
|
||||
async def test_missing_note_tool_error(nc_mcp_client: ClientSession):
|
||||
"""Test that accessing a non-existent note via tool returns proper error."""
|
||||
# Try to get a non-existent note via tool - should return error response
|
||||
response = await nc_mcp_client.call_tool("nc_notes_get_note", {"note_id": 999999})
|
||||
|
||||
# Should return error response (not raise exception) for tools
|
||||
assert response is not None
|
||||
assert response.isError is True
|
||||
assert "Note 999999 not found" in response.content[0].text
|
||||
|
||||
|
||||
@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
|
||||
# Try to delete a non-existent note - should return error response
|
||||
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
|
||||
# Should return error response (not raise exception) for tools
|
||||
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"]
|
||||
assert response.isError is True
|
||||
assert "Note 999999 not found" in response.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -81,7 +76,7 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl
|
||||
note_id = note_data["id"]
|
||||
|
||||
try:
|
||||
# Try to update with invalid ETag
|
||||
# Try to update with invalid ETag - should return error response
|
||||
response = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
@@ -93,16 +88,10 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Invalid ETag response: {response}")
|
||||
|
||||
# Should return structured error response with improved message
|
||||
# Should return error response (not raise exception) for tools
|
||||
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"]
|
||||
)
|
||||
assert response.isError is True
|
||||
assert "modified by someone else" in response.content[0].text
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
|
||||
@@ -51,6 +51,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"nc_calendar_find_availability",
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
"deck_create_board",
|
||||
]
|
||||
|
||||
for expected_tool in expected_tools:
|
||||
@@ -67,7 +68,8 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
template_uris.append(template.uriTemplate)
|
||||
|
||||
# Verify expected resource templates
|
||||
expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"]
|
||||
# Note: Notes attachments are now handled via tools, not resource templates
|
||||
expected_templates = []
|
||||
|
||||
for expected_template in expected_templates:
|
||||
assert expected_template in template_uris, (
|
||||
@@ -83,7 +85,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
expected_resources = ["nc://capabilities", "notes://settings"]
|
||||
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
|
||||
|
||||
for expected_resource in expected_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
@@ -123,8 +125,11 @@ async def test_mcp_notes_crud_workflow(
|
||||
created_note = create_result.content[0].text
|
||||
note_data = json.loads(created_note) # Parse the returned JSON
|
||||
note_id = note_data["id"]
|
||||
create_etag = note_data["etag"] # Verify create response includes ETag
|
||||
|
||||
logger.info(f"Note created via MCP with ID: {note_id}")
|
||||
logger.info(f"Note created via MCP with ID: {note_id}, ETag: {create_etag}")
|
||||
assert "etag" in note_data, "Create response should include ETag"
|
||||
assert create_etag, "Create ETag should not be empty"
|
||||
|
||||
# 2. Verify creation via direct NextcloudClient
|
||||
direct_note = await nc_client.notes.get_note(note_id)
|
||||
@@ -136,9 +141,11 @@ async def test_mcp_notes_crud_workflow(
|
||||
|
||||
# 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)
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_get_note", {"note_id": note_id}
|
||||
)
|
||||
read_note_data = read_result.content[0].text
|
||||
read_note_data = json.loads(read_note_data)
|
||||
|
||||
assert read_note_data["title"] == test_title
|
||||
assert read_note_data["content"] == test_content
|
||||
@@ -165,6 +172,15 @@ async def test_mcp_notes_crud_workflow(
|
||||
f"MCP note update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# Verify update response includes new ETag
|
||||
updated_note_data = json.loads(update_result.content[0].text)
|
||||
update_etag = updated_note_data["etag"]
|
||||
|
||||
logger.info(f"Note updated via MCP, new ETag: {update_etag}")
|
||||
assert "etag" in updated_note_data, "Update response should include ETag"
|
||||
assert update_etag, "Update ETag should not be empty"
|
||||
assert update_etag != etag, "ETag should change after update"
|
||||
|
||||
# 5. Verify update via direct NextcloudClient
|
||||
updated_direct_note = await nc_client.notes.get_note(note_id)
|
||||
assert updated_direct_note["title"] == updated_title
|
||||
@@ -181,6 +197,15 @@ async def test_mcp_notes_crud_workflow(
|
||||
f"MCP note append failed: {append_result.content}"
|
||||
)
|
||||
|
||||
# Verify append response includes new ETag
|
||||
appended_note_data = json.loads(append_result.content[0].text)
|
||||
append_etag = appended_note_data["etag"]
|
||||
|
||||
logger.info(f"Content appended via MCP, new ETag: {append_etag}")
|
||||
assert "etag" in appended_note_data, "Append response should include ETag"
|
||||
assert append_etag, "Append ETag should not be empty"
|
||||
assert append_etag != update_etag, "ETag should change after append"
|
||||
|
||||
# 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"]
|
||||
@@ -256,6 +281,82 @@ async def test_mcp_notes_crud_workflow(
|
||||
logger.warning(f"Failed to cleanup note: {e}")
|
||||
|
||||
|
||||
async def test_mcp_notes_etag_conflict(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test that MCP note updates fail when using stale ETags."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
test_title = f"ETag Test Note {unique_suffix}"
|
||||
test_content = f"This is test content for ETag testing {unique_suffix}"
|
||||
test_category = "ETTesting"
|
||||
|
||||
created_note = None
|
||||
|
||||
try:
|
||||
# 1. Create note via MCP
|
||||
logger.info(f"Creating note for ETag conflict test: {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
|
||||
note_data = json.loads(create_result.content[0].text)
|
||||
note_id = note_data["id"]
|
||||
original_etag = note_data["etag"]
|
||||
created_note = note_data
|
||||
|
||||
# 2. Update note to change ETag
|
||||
first_update_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"etag": original_etag,
|
||||
"title": f"First Update {test_title}",
|
||||
"content": test_content,
|
||||
"category": test_category,
|
||||
},
|
||||
)
|
||||
|
||||
assert first_update_result.isError is False
|
||||
updated_data = json.loads(first_update_result.content[0].text)
|
||||
new_etag = updated_data["etag"]
|
||||
assert new_etag != original_etag, "ETag should have changed after update"
|
||||
|
||||
# 3. Try to update with the stale (original) ETag - this should fail
|
||||
logger.info(f"Attempting update with stale ETag: {original_etag}")
|
||||
conflict_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
"note_id": note_id,
|
||||
"etag": original_etag, # Use stale ETag
|
||||
"title": "This should fail",
|
||||
"content": "This update should be rejected",
|
||||
"category": test_category,
|
||||
},
|
||||
)
|
||||
|
||||
# 4. Verify the update was rejected with a 412 error
|
||||
# With McpError, tools now return proper error responses
|
||||
assert conflict_result.isError is True, "Update with stale ETag should fail"
|
||||
response_content = conflict_result.content[0].text
|
||||
assert "modified by someone else" in response_content, (
|
||||
f"Expected conflict error message, got: {response_content}"
|
||||
)
|
||||
|
||||
logger.info("Successfully verified ETag conflict handling via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_note is not None:
|
||||
try:
|
||||
await nc_client.notes.delete_note(created_note["id"])
|
||||
logger.info(f"Cleaned up test note {created_note['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup test note: {e}")
|
||||
|
||||
|
||||
async def test_mcp_webdav_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for Pydantic models and serialization."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from nextcloud_mcp_server.models.base import BaseResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_timestamp_format_validation():
|
||||
"""Test that timestamps in BaseResponse are RFC3339 compliant for MCP validation.
|
||||
|
||||
This test should initially fail, demonstrating the timestamp validation error
|
||||
seen in MCP inspector. MCP expects RFC3339 format with timezone information.
|
||||
"""
|
||||
# Create a response object
|
||||
response = BaseResponse()
|
||||
|
||||
# Serialize to JSON (mimics what MCP inspector sees)
|
||||
json_str = response.model_dump_json()
|
||||
data = json.loads(json_str)
|
||||
|
||||
timestamp_str = data["timestamp"]
|
||||
|
||||
# RFC3339 regex pattern (what MCP expects)
|
||||
# Format: YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM]
|
||||
rfc3339_pattern = (
|
||||
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
|
||||
)
|
||||
|
||||
# This assertion should FAIL with current implementation
|
||||
assert re.match(rfc3339_pattern, timestamp_str), (
|
||||
f"Timestamp '{timestamp_str}' is not RFC3339 compliant. "
|
||||
f"MCP expects format like '2025-08-30T19:22:58.862377Z' or '2025-08-30T19:22:58.862377+00:00'"
|
||||
)
|
||||
|
||||
|
||||
def test_base_response_timestamp_is_utc():
|
||||
"""Test that BaseResponse timestamps are in UTC timezone."""
|
||||
response = BaseResponse()
|
||||
|
||||
# The timestamp should be timezone-aware and in UTC
|
||||
assert response.timestamp.tzinfo is not None, (
|
||||
"Timestamp should have timezone information"
|
||||
)
|
||||
assert response.timestamp.tzinfo == timezone.utc, (
|
||||
"Timestamp should be in UTC timezone"
|
||||
)
|
||||
|
||||
|
||||
def test_serialized_timestamp_ends_with_z_or_offset():
|
||||
"""Test that serialized timestamps have proper timezone suffix."""
|
||||
response = BaseResponse()
|
||||
json_str = response.model_dump_json()
|
||||
data = json.loads(json_str)
|
||||
|
||||
timestamp_str = data["timestamp"]
|
||||
|
||||
# Should end with 'Z' (UTC) or timezone offset like '+00:00'
|
||||
assert timestamp_str.endswith("Z") or re.search(
|
||||
r"[+-]\d{2}:\d{2}$", timestamp_str
|
||||
), (
|
||||
f"Timestamp '{timestamp_str}' should end with 'Z' or timezone offset like '+00:00'"
|
||||
)
|
||||
|
||||
|
||||
def test_current_broken_format():
|
||||
"""Test showing the current broken timestamp format that causes MCP validation errors."""
|
||||
# This demonstrates what the current code produces
|
||||
current_naive_dt = datetime.now()
|
||||
current_format = current_naive_dt.isoformat()
|
||||
|
||||
# Show that current format lacks timezone info
|
||||
assert "Z" not in current_format
|
||||
assert "+" not in current_format
|
||||
assert "-" not in current_format[-6:] # Check last 6 chars for timezone
|
||||
|
||||
logger.info(f"Current broken format: {current_format}")
|
||||
logger.info(
|
||||
"This format causes MCP validation errors because it lacks timezone information"
|
||||
)
|
||||
Reference in New Issue
Block a user