Add support for attachments in notes

This commit is contained in:
Chris Coutinho
2025-05-06 02:52:51 +02:00
parent 973caefb74
commit 04e4a8e0a8
21 changed files with 1493 additions and 6 deletions
+11
View File
@@ -13,6 +13,8 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin
* `nc_notes_create_note`: Create a new note.
* `nc_notes_update_note`: Update an existing note by ID.
* `nc_notes_delete_note`: Delete a note by ID.
* `nc_notes_search_notes`: Search notes by title or content.
* `nc_get_note`: Get a specific note by ID.
### Available Resources
@@ -20,6 +22,15 @@ Currently, the server primarily interacts with the Nextcloud Notes API, providin
* `notes://all`: Access all notes.
* `notes://settings`: Access note settings.
* `nc://capabilities`: Access Nextcloud server capabilities.
* `nc://Notes/{note_id}/attachments/{attachment_filename}`: Access attachments for notes.
### 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
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python
import os
import sys
from nextcloud_mcp_server.client import NextcloudClient
def main():
note_id = 487 # ID of the note we just created
# Create client
client = NextcloudClient.from_env()
# Check if image exists
image_path = 'sample_image.png'
if not os.path.exists(image_path):
print(f"Error: Image file '{image_path}' not found")
return 1
# Read the image
with open(image_path, 'rb') as f:
image_content = f.read()
print(f"Attaching image to note {note_id}...")
try:
# Attach the image to the note
upload_response = client.add_note_attachment(
note_id=note_id,
filename="sample_image.png",
content=image_content,
mime_type="image/png"
)
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
# Now get the current note to get its etag
note = client.notes_get_note(note_id=note_id)
etag = note["etag"]
# Update the note content to include the image references
updated_content = f"""# Note with Visible Image Demo
This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface.
We'll include the sample red square image we created earlier using both Markdown and HTML methods.
## Method 1: Markdown Image Syntax
![Sample Red Square Image](.attachments.{note_id}/sample_image.png)
## Method 2: HTML Image Tag
<img src=".attachments.{note_id}/sample_image.png" alt="Sample Red Square Image" width="300" />
## Image Path Details
The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png`
"""
# Update the note with the references to the image
updated_note = client.notes_update_note(
note_id=note_id,
etag=etag,
content=updated_content
)
print(f"Note updated with image references. You can now view it in the browser.")
print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
return 0
except Exception as e:
print(f"Error: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python
import os
import sys
from nextcloud_mcp_server.client import NextcloudClient
def main():
note_id = 420 # ID of the note we created earlier
# Create client
client = NextcloudClient.from_env()
# Check if image exists
image_path = 'sample_image.png'
if not os.path.exists(image_path):
print(f"Error: Image file '{image_path}' not found")
return 1
# Read the image
with open(image_path, 'rb') as f:
image_content = f.read()
print(f"Attaching image to note {note_id}...")
try:
# Attach the image to the note
upload_response = client.add_note_attachment(
note_id=note_id,
filename="sample_image.png",
content=image_content,
mime_type="image/png"
)
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
return 0
except Exception as e:
print(f"Error attaching image: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python
import sys
from nextcloud_mcp_server.client import NextcloudClient
def main():
note_id = 420 # ID of the note with the image attachment
# Create client
client = NextcloudClient.from_env()
# Get the note again to see the updated content
try:
note = client.notes_get_note(note_id=note_id)
print(f"Retrieved note: {note['title']}")
print("\nCURRENT NOTE CONTENT:")
print("-" * 50)
print(note['content'])
print("-" * 50)
return 0
except Exception as e:
print(f"Error retrieving note: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+118
View File
@@ -0,0 +1,118 @@
# Working with Images in Nextcloud Notes
This document explains how to properly work with images and attachments in Nextcloud Notes through the MCP server.
## Adding Image Attachments
Images and other files can be attached to notes using the WebDAV protocol. The Nextcloud MCP server handles this through the `add_note_attachment` method:
```python
# Example: Adding an image attachment to a note
client.add_note_attachment(
note_id=123, # The ID of the note
filename="image.png", # The filename for the attachment
content=image_bytes, # The binary content of the image
mime_type="image/png" # The MIME type
)
```
## Embedding Images in Notes
For images to display inline within notes, you must reference them correctly in the note content. There are two methods:
### 1. Markdown Syntax (Recommended)
```markdown
![Image Alt Text](.attachments.{note_id}/{filename})
```
For example:
```markdown
![My Screenshot](.attachments.123/screenshot.png)
```
### 2. HTML Image Tags
```html
<img src=".attachments.{note_id}/{filename}" alt="Image description" width="300" />
```
For example:
```html
<img src=".attachments.123/screenshot.png" alt="My Screenshot" width="300" />
```
## Storage Location
Image attachments are stored in a hidden directory structure:
```
/Notes/.attachments.{note_id}/{filename}
```
This path is accessible via WebDAV, allowing direct file operations.
## Orphaned Attachments Behavior
**Important:** When notes are deleted, their attachments remain in the system. This is the expected behavior of the official Nextcloud Notes app, not a bug in the MCP server implementation.
Consequences:
- Orphaned attachments accumulate over time
- No automatic cleanup of attachment directories
- References to attachments in deleted notes become broken links
## Examples
### Complete Example: Creating a Note with Embedded Image
```python
from nextcloud_mcp_server.client import NextcloudClient
import os
# Create client
client = NextcloudClient.from_env()
# 1. Create the note
note = client.notes_create_note(
title="Note with Embedded Image",
content="# Image Example\n\nThis note will have an embedded image.",
category="Documentation"
)
note_id = note["id"]
note_etag = note["etag"]
# 2. Read image content
with open("example.png", "rb") as f:
image_content = f.read()
# 3. Upload image as attachment
client.add_note_attachment(
note_id=note_id,
filename="example.png",
content=image_content,
mime_type="image/png"
)
# 4. Update note content to include image reference
updated_content = f"""# Image Example
This note has an embedded image below:
![Example Image](.attachments.{note_id}/example.png)
"""
# 5. Update the note with image reference
client.notes_update_note(
note_id=note_id,
etag=note_etag,
content=updated_content
)
```
## Troubleshooting
If you encounter issues with attachments:
1. **401 Unauthorized errors**: Verify WebDAV permissions in Nextcloud
2. **Images not displaying**: Check the exact path format (`.attachments.{note_id}/{filename}`)
3. **Attachment access after note deletion**: This is expected - attachments persist after note deletion
+172 -4
View File
@@ -1,5 +1,7 @@
import os
import time # Import time for sleep
import mimetypes
from io import BytesIO
from httpx import (
Client,
Auth,
@@ -32,8 +34,8 @@ def log_response(response: Response):
class NextcloudClient:
def __init__(self, base_url: str, auth: Auth | None = None):
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
self.username = username # Store username
self._client = Client(
base_url=base_url,
auth=auth,
@@ -48,7 +50,8 @@ class NextcloudClient:
host = os.environ["NEXTCLOUD_HOST"]
username = os.environ["NEXTCLOUD_USERNAME"]
password = os.environ["NEXTCLOUD_PASSWORD"]
return cls(base_url=host, auth=BasicAuth(username, password))
# Pass username to constructor
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
def capabilities(self):
@@ -155,6 +158,171 @@ class NextcloudClient:
return search_results
def notes_delete_note(self, *, note_id: int):
# First delete the note through the Notes API
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
response.raise_for_status()
return response.json()
json_response = response.json()
# Then try to delete the attachments directory via WebDAV
try:
webdav_base = self._get_webdav_base_path()
attachments_dir = f"{webdav_base}/Notes/.attachments.{note_id}"
logger.info("Deleting attachment directory: %s", attachments_dir)
delete_response = self._client.request("DELETE", attachments_dir)
# 204 No Content = successful delete, 404 Not Found = already gone (both OK)
if delete_response.status_code not in [204, 404]:
logger.warning(
"Unexpected status code %s when deleting attachments directory for note %s",
delete_response.status_code,
note_id
)
# In production, we should not raise an error if the Notes API deletion was successful
# but WebDAV cleanup failed - this would leave the note inaccessible to users.
# Instead, log the issue for admin attention.
if delete_response.status_code == 401:
logger.error(
"Authentication error when trying to delete attachment directory for note %s. "
"Please verify WebDAV permissions.",
note_id
)
elif delete_response.status_code >= 400:
logger.error(
"Error (HTTP %s) when trying to delete attachment directory for note %s.",
delete_response.status_code,
note_id
)
except Exception as e:
# Log but don't fail the operation if attachments cleanup fails
logger.error(
"Error cleaning up attachments directory for note %s: %s",
note_id,
e
)
return json_response
# Removed incorrect get_note_attachment method that used Notes API
def _get_webdav_base_path(self) -> str:
"""Helper to get the base WebDAV path for the authenticated user."""
# Use the stored username
return f"/remote.php/dav/files/{self.username}"
def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, mime_type: str | None = None):
"""Add/Update an attachment to a note via WebDAV PUT."""
# Attachments are stored in a hidden folder .attachments.{note_id} within the Notes folder
webdav_base = self._get_webdav_base_path()
attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}"
logger.info("Uploading attachment to WebDAV path: %s", attachment_path)
# Log current auth settings to diagnose the issue
logger.info("WebDAV auth settings - Username: %s, Auth Type: %s",
self.username, type(self._client.auth).__name__)
if not mime_type:
mime_type, _ = mimetypes.guess_type(filename)
if not mime_type:
mime_type = "application/octet-stream" # Default if guessing fails
headers = {"Content-Type": mime_type}
try:
# First check if we can access WebDAV at all with current credentials
# by checking the Notes directory
notes_dir_path = f"{webdav_base}/Notes"
logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path)
notes_dir_response = self._client.request("PROPFIND", notes_dir_path,
headers={"Depth": "0"})
if notes_dir_response.status_code == 401:
logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.")
raise HTTPStatusError(
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
request=notes_dir_response.request,
response=notes_dir_response
)
elif notes_dir_response.status_code >= 400:
logger.error("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code)
notes_dir_response.raise_for_status()
else:
logger.info("Successfully accessed WebDAV Notes directory (Status: %s)",
notes_dir_response.status_code)
# Ensure the parent directory exists using MKCOL
parent_dir_path = f"{webdav_base}/Notes/.attachments.{note_id}"
logger.info("Creating attachments directory: %s", parent_dir_path)
mkcol_response = self._client.request("MKCOL", parent_dir_path)
# MKCOL should return 201 Created or 405 Method Not Allowed (if exists)
# We can ignore 405, but raise for other errors
if mkcol_response.status_code not in [201, 405]:
logger.warning(
"Unexpected status code %s when creating attachments directory",
mkcol_response.status_code
)
mkcol_response.raise_for_status()
else:
logger.info("Created/verified directory: %s (Status: %s)",
parent_dir_path, mkcol_response.status_code)
# Proceed with the PUT request
logger.info("Putting attachment file to: %s", attachment_path)
response = self._client.put(
attachment_path,
content=content,
headers=headers
)
response.raise_for_status() # Raises for 4xx/5xx status codes
logger.info("Successfully uploaded attachment '%s' to note %s (Status: %s)", filename, note_id, response.status_code)
# PUT typically returns 201 Created or 204 No Content on success
return {"status_code": response.status_code} # Return status or relevant info
except HTTPStatusError as e:
logger.error(
"HTTP error uploading attachment '%s' to note %s: %s",
filename,
note_id,
e,
)
raise e
except Exception as e:
logger.error(
"Unexpected error uploading attachment '%s' to note %s: %s",
filename,
note_id,
e,
)
raise e
def get_note_attachment(self, *, note_id: int, filename: str):
"""Fetch a specific attachment from a note via WebDAV GET."""
webdav_base = self._get_webdav_base_path()
attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}"
logger.info("Fetching attachment from WebDAV path: %s", attachment_path)
try:
response = self._client.get(attachment_path)
response.raise_for_status()
content = response.content
mime_type = response.headers.get("content-type", "application/octet-stream")
logger.info("Successfully fetched attachment '%s' (%s, %d bytes)", filename, mime_type, len(content))
return content, mime_type
except HTTPStatusError as e:
logger.error(
"HTTP error fetching attachment '%s' for note %s: %s",
filename,
note_id,
e,
)
raise e
except Exception as e:
logger.error(
"Unexpected error fetching attachment '%s' for note %s: %s",
filename,
note_id,
e,
)
raise e
+20
View File
@@ -108,6 +108,26 @@ def nc_notes_delete_note(note_id: int, ctx: Context):
return client.notes_delete_note(note_id=note_id)
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
def nc_notes_get_attachment(note_id: int, attachment_filename: str):
"""Get a specific attachment from a note"""
ctx = mcp.get_context()
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Assuming a method get_note_attachment exists in the client
# This method should return the raw content and determine the mime type
content, mime_type = client.get_note_attachment(note_id=note_id, filename=attachment_filename)
return {
"contents": [
{
# Use uppercase 'Notes' to match the decorator
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
"mimeType": mime_type, # Client needs to determine this
"data": content, # Return raw bytes/data
}
]
}
def run():
mcp.run()
Generated
+101 -1
View File
@@ -478,6 +478,106 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pillow"
version = "11.2.1"
description = "Python Imaging Library (Fork)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"},
{file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"},
{file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"},
{file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"},
{file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"},
{file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"},
{file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"},
{file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"},
{file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"},
{file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"},
{file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"},
{file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"},
{file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"},
{file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"},
{file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"},
{file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"},
{file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"},
{file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"},
{file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"},
{file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"},
{file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"},
{file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"},
{file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"},
{file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"},
{file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"},
{file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"},
{file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"},
{file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"},
{file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"},
{file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"},
{file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"},
{file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"},
{file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"},
{file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"},
{file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"},
{file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"},
{file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"},
{file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"},
{file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"},
{file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"},
{file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"},
{file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"},
{file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"]
typing = ["typing-extensions ; python_version < \"3.10\""]
xmp = ["defusedxml"]
[[package]]
name = "platformdirs"
version = "4.3.7"
@@ -973,4 +1073,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "9f0b7b38edcfb60fb521fd54a2b43c2a8d8ab2e3bf0c7b5e994a4999fc9d954e"
content-hash = "38328edadd5d23977a5c867229a1445013862796853bc5e8e9de08106ecba60f"
+2 -1
View File
@@ -9,7 +9,8 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mcp[cli] (>=1.7,<1.8)",
"httpx (>=0.28.1,<0.29.0)"
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=11.2.1,<12.0.0)"
]
[project.scripts]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python
from PIL import Image, ImageDraw
# Create a simple image (a red square with some text)
img = Image.new('RGB', (200, 200), color = (255, 255, 255))
draw = ImageDraw.Draw(img)
draw.rectangle([(20, 20), (180, 180)], fill=(255, 0, 0))
draw.text((40, 100), "Nextcloud MCP", fill=(255, 255, 255))
img.save('sample_image.png')
print("Image created successfully: sample_image.png")
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python
import sys
import time
from nextcloud_mcp_server.client import NextcloudClient
def main():
# Create client
client = NextcloudClient.from_env()
# 1. Create a new test note
test_title = "Test Note for Deletion with Attachment"
print(f"Creating test note: {test_title}...")
note = client.notes_create_note(
title=test_title,
content="This note will be deleted but its attachment should remain.",
category="Test"
)
note_id = note["id"]
print(f"Note created with ID: {note_id}")
# 2. Attach the existing image to the note
print(f"Attaching image to note {note_id}...")
with open("sample_image.png", 'rb') as f:
image_content = f.read()
upload_response = client.add_note_attachment(
note_id=note_id,
filename="deletion_test_image.png",
content=image_content,
mime_type="image/png"
)
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
# 3. Verify the attachment exists
print(f"Verifying attachment exists...")
content, mime_type = client.get_note_attachment(
note_id=note_id,
filename="deletion_test_image.png"
)
print(f"Attachment verified (Size: {len(content)} bytes)")
# 4. Delete the note
print(f"\nDeleting note {note_id}...")
response = client.notes_delete_note(note_id=note_id)
print(f"Note deleted successfully.")
# Wait a moment for deletion to process
time.sleep(1)
# 5. Verify the note is gone
print("\nVerifying note is deleted...")
try:
client.notes_get_note(note_id=note_id)
print("ERROR: Note still exists!")
return 1
except Exception as e:
print(f"Note confirmed deleted (404 Not Found expected): {e}")
# 6. Check if attachment still exists (expected behavior)
print("\nChecking if attachment still exists (orphaned)...")
try:
content, mime_type = client.get_note_attachment(
note_id=note_id,
filename="deletion_test_image.png"
)
print("EXPECTED BEHAVIOR: Attachment still exists after note deletion!")
print(f"Attachment size: {len(content)} bytes")
print(f"This matches the documented behavior of Nextcloud Notes.")
# Save the orphaned attachment to verify
output_path = "orphaned_attachment.png"
with open(output_path, 'wb') as f:
f.write(content)
print(f"Saved orphaned attachment to: {output_path}")
return 0
except Exception as e:
print(f"Unexpected: Attachment was deleted with note: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+255
View File
@@ -156,3 +156,258 @@ def test_delete_nonexistent_note(nc_client: NextcloudClient):
print(
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
)
@pytest.mark.integration
def test_note_attachment_integration(nc_client: NextcloudClient):
"""
Integration test for adding and retrieving a note attachment via WebDAV.
This test is conditional on WebDAV permissions being available.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Attachment Test Note {unique_id}"
note_content = "Note for testing attachments."
note_category = "AttachmentTesting"
created_note = None
note_id = None
try:
print(f"\nCreating note for attachment test: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title, content=note_content, category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note created with ID: {note_id}")
time.sleep(1) # Allow time for note creation
# --- Try to Add Attachment ---
attachment_filename = f"test_attachment_{unique_id}.txt"
attachment_content = f"This is the content of {attachment_filename}".encode('utf-8')
attachment_mime = "text/plain"
print(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}")
try:
# Try to add the attachment, but don't fail the test if WebDAV isn't available
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type=attachment_mime
)
# If we get here, WebDAV is working - continue with attachment tests
assert upload_response and "status_code" in upload_response
assert upload_response["status_code"] in [201, 204]
print(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).")
time.sleep(1) # Allow time for upload processing
# --- Get and Verify Attachment ---
print(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}")
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
# --- Verify Attachment ---
assert retrieved_content == attachment_content
# Check if the expected mime type is part of the retrieved one (to handle charset)
assert attachment_mime in retrieved_mime
print("Retrieved attachment content and mime type verified successfully.")
except HTTPStatusError as e:
if e.response.status_code == 401:
pytest.skip("Skipping attachment tests due to WebDAV permission issues (401 Unauthorized)")
else:
raise # Re-raise other HTTP errors
finally:
# --- Delete Note (Cleanup) ---
if note_id:
print(f"Attempting cleanup: deleting note ID: {note_id}")
try:
nc_client.notes_delete_note(note_id=note_id)
print(f"Note ID: {note_id} deleted successfully.")
# Verify deletion
time.sleep(1)
with pytest.raises(HTTPStatusError) as excinfo_del:
nc_client.notes_get_note(note_id=note_id)
assert excinfo_del.value.response.status_code == 404
print(f"Verified note {note_id} deletion (404 received).")
except Exception as e:
print(f"Error during cleanup (deleting note {note_id}): {e}")
else:
print("Skipping cleanup as note ID was not obtained.")
@pytest.mark.integration
def test_note_attachment_with_category_integration(nc_client: NextcloudClient):
"""
Explicitly tests adding/retrieving an attachment for a note WITH a category.
Functionally similar to test_note_attachment_integration but emphasizes the category.
"""
# --- Create Note with Category ---
unique_id = str(uuid.uuid4())
note_title = f"Category Attachment Test Note {unique_id}"
note_content = "Note with category for testing attachments."
note_category = "CategoryTest" # Explicitly using a category
created_note = None
note_id = None
try:
print(f"\nCreating note with category '{note_category}' for attachment test: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title, content=note_content, category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note with category created with ID: {note_id}")
time.sleep(1)
# --- Try to Add Attachment ---
attachment_filename = f"category_test_attachment_{unique_id}.txt"
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
attachment_mime = "text/plain"
print(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}")
try:
# Try to add the attachment, but don't fail the test if WebDAV isn't available
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type=attachment_mime
)
# If we get here, WebDAV is working - continue with attachment tests
assert upload_response and "status_code" in upload_response
assert upload_response["status_code"] in [201, 204]
print(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# --- Get and Verify Attachment ---
print(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}")
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
# --- Verify Attachment ---
assert retrieved_content == attachment_content
assert attachment_mime in retrieved_mime # Check if expected mime is part of retrieved
print("Retrieved attachment content and mime type verified successfully for note with category.")
except HTTPStatusError as e:
if e.response.status_code == 401:
pytest.skip("Skipping attachment tests due to WebDAV permission issues (401 Unauthorized)")
else:
raise # Re-raise other HTTP errors
finally:
# --- Delete Note (Cleanup) ---
if note_id:
print(f"Attempting cleanup: deleting note ID: {note_id}")
try:
nc_client.notes_delete_note(note_id=note_id)
print(f"Note ID: {note_id} deleted successfully.")
time.sleep(1)
with pytest.raises(HTTPStatusError) as excinfo_del:
nc_client.notes_get_note(note_id=note_id)
assert excinfo_del.value.response.status_code == 404
print(f"Verified note {note_id} deletion (404 received).")
except Exception as e:
print(f"Error during cleanup (deleting note {note_id}): {e}")
else:
print("Skipping cleanup as note ID was not obtained.")
@pytest.mark.integration
def test_attachment_cleanup_behavior(nc_client: NextcloudClient):
"""
Test to document the behavior regarding note attachment cleanup.
This test confirms that when a note is deleted, its attachments remain in the system.
This matches the behavior of the official Nextcloud Notes app, which also leaves
orphaned attachments when notes are deleted.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Attachment Cleanup Test {unique_id}"
note_content = "Test note for attachments cleanup."
note_category = "AttachmentCleanupTest"
print(f"\nCreating test note: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title, content=note_content, category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Test note created with ID: {note_id}")
time.sleep(1)
# Check authentication type
auth_type = type(nc_client._client.auth).__name__
print(f"Client authentication type: {auth_type}")
# --- Try to Add Attachment ---
attachment_filename = f"cleanup_test_{unique_id}.txt"
attachment_content = f"Content for cleanup test".encode('utf-8')
print(f"Adding attachment '{attachment_filename}' to note ID: {note_id}")
try:
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type="text/plain"
)
assert upload_response["status_code"] in [201, 204]
print(f"Attachment added successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# --- Verify Attachment Exists ---
retrieved_content, _ = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert retrieved_content == attachment_content
print("Verified attachment exists and can be retrieved")
# Attachment operations successful - continue with test
has_webdav_access = True
except HTTPStatusError as e:
if e.response.status_code == 401:
print(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.")
pytest.skip("WebDAV access denied (401 Unauthorized)")
else:
raise # Re-raise other HTTP errors
# --- Delete Note ---
print(f"Deleting note ID: {note_id}")
nc_client.notes_delete_note(note_id=note_id)
print(f"Note ID: {note_id} deleted successfully.")
time.sleep(1)
# --- Verify Note Is Deleted ---
with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_get_note(note_id=note_id)
assert excinfo.value.response.status_code == 404
print(f"Verified note deletion (404 received)")
# --- Document the expected behavior: attachments remain after note deletion ---
try:
# Try to get the attachment - expected to still exist
retrieved_content, _ = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print("EXPECTED BEHAVIOR: Attachment still exists after note deletion")
print("This matches the behavior of the official Nextcloud Notes app")
except HTTPStatusError as e:
if e.response.status_code == 404:
print("NOTE: Attachment was deleted with the note (unexpected but not a problem)")
else:
print(f"Unexpected error when checking attachment: {e.response.status_code}")
+126
View File
@@ -0,0 +1,126 @@
import pytest
import os
import time
import uuid
import tempfile
from PIL import Image, ImageDraw
from io import BytesIO
from nextcloud_mcp_server.client import NextcloudClient
@pytest.fixture(scope="module")
def nc_client() -> NextcloudClient:
"""
Fixture to create a NextcloudClient instance for integration tests.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
return NextcloudClient.from_env()
@pytest.fixture
def test_image():
"""Generate a test image with embedded text for attachment tests"""
# Create a temporary file to store the test image
fd, image_path = tempfile.mkstemp(suffix='.png')
os.close(fd)
# Create a test image with text
img = Image.new('RGB', (300, 200), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212))
draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255))
img.save(image_path)
try:
yield image_path
finally:
# Clean up the temporary image file
if os.path.exists(image_path):
os.unlink(image_path)
@pytest.mark.integration
def test_note_with_embedded_image(nc_client: NextcloudClient, test_image):
"""
Test creating a note with an embedded image and verify the process works end-to-end.
This test documents how images should be embedded in Nextcloud Notes.
"""
# Generate a unique identifier for this test run
unique_id = str(uuid.uuid4())[:8]
note_title = f"Embedded Image Test {unique_id}"
initial_content = "# Embedded Image Test\n\nThis note demonstrates how to properly embed images in Nextcloud Notes."
# Create the note
print(f"Creating test note: {note_title}")
note = nc_client.notes_create_note(
title=note_title,
content=initial_content,
category="Documentation"
)
note_id = note["id"]
note_etag = note["etag"]
print(f"Note created with ID: {note_id}")
try:
# Read the test image content
with open(test_image, 'rb') as f:
image_content = f.read()
# Generate a unique filename for the attachment
attachment_filename = f"test_image_{unique_id}.png"
# Upload the image as an attachment
print(f"Uploading image attachment '{attachment_filename}' to note {note_id}...")
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=image_content,
mime_type="image/png"
)
print(f"Image uploaded: {upload_response}")
# Update the note content to include the embedded image using Markdown syntax
# This is the correct syntax for embedding images in Nextcloud Notes
updated_content = f"""# Embedded Image Test
This note demonstrates how to properly embed images in Nextcloud Notes.
## Method 1: Markdown Image Syntax
![Test Image](.attachments.{note_id}/{attachment_filename})
## Method 2: HTML Image Tag
<img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="300" />
## Notes on Image Embedding
- Images must be stored in the .attachments.{note_id} directory
- Images are referenced using relative paths
- Both Markdown and HTML image tags work in Nextcloud Notes
- The Nextcloud Notes UI will display these images inline when viewing the note
"""
# Update the note with the image references
print("Updating note content with image references...")
updated_note = nc_client.notes_update_note(
note_id=note_id,
etag=note_etag,
content=updated_content
)
# Verify the updated note has the correct content
retrieved_note = nc_client.notes_get_note(note_id=note_id)
assert ".attachments." in retrieved_note["content"], "Image reference not found in note content"
print("Note updated successfully with image references")
# Verify we can retrieve the image attachment
retrieved_content, mime_type = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert len(retrieved_content) > 0, "Retrieved image content is empty"
assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}"
print("Test completed successfully - image was embedded in the note and can be retrieved")
finally:
# Clean up - delete the test note
print(f"Cleaning up - deleting test note {note_id}")
nc_client.notes_delete_note(note_id=note_id)
+100
View File
@@ -0,0 +1,100 @@
import pytest
import os
import time
import uuid
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set
@pytest.fixture(scope="module")
def nc_client() -> NextcloudClient:
"""
Fixture to create a NextcloudClient instance for integration tests.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
return NextcloudClient.from_env()
@pytest.mark.integration
def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient):
"""
Test to verify and document that when a note is deleted, its attachments remain
in the system. This is the expected behavior of the Nextcloud Notes app.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Attachment Cleanup Test {unique_id}"
note_content = f"# Test for attachment cleanup behavior\n\nThis note will be deleted, but attachments should remain."
note_category = "CleanupTests"
created_note = None
note_id = None
try:
# Create the note
print(f"Creating note: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title,
content=note_content,
category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note created with ID: {note_id}")
time.sleep(1)
# Create a simple text attachment
attachment_filename = f"orphan_test_{unique_id}.txt"
attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8')
# Attach the file to the note
print(f"Attaching text file to note {note_id}...")
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type="text/plain"
)
assert upload_response["status_code"] in [201, 204]
print(f"Attachment added successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# Verify the attachment exists
content, mime_type = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert content == attachment_content, "Attachment content mismatch"
print("Attachment verified")
# Now delete the note
print(f"Deleting note ID: {note_id}")
nc_client.notes_delete_note(note_id=note_id)
print(f"Note deleted successfully.")
time.sleep(1)
# Verify the note is deleted
with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_get_note(note_id=note_id)
assert excinfo.value.response.status_code == 404
print(f"Verified note deletion (404 Not Found)")
# Now check if the attachment still exists (expected behavior: it should)
print(f"Checking if attachment still exists after note deletion...")
orphaned_content, orphaned_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
# If we get here without an exception, the attachment still exists
print("CONFIRMED: Attachment still exists after note deletion")
print("This is the expected behavior of the Nextcloud Notes app")
assert orphaned_content == attachment_content, "Orphaned attachment content mismatch"
finally:
# No cleanup needed since we've already deleted the note
pass
+133
View File
@@ -0,0 +1,133 @@
import pytest
import os
import time
import uuid
import tempfile
from httpx import HTTPStatusError
from PIL import Image, ImageDraw
from nextcloud_mcp_server.client import NextcloudClient
# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set
@pytest.fixture(scope="module")
def nc_client() -> NextcloudClient:
"""
Fixture to create a NextcloudClient instance for integration tests.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
return NextcloudClient.from_env()
@pytest.fixture
def test_image():
"""Generate a test image for attachment tests"""
# Create a temporary file to store the test image
fd, image_path = tempfile.mkstemp(suffix='.png')
os.close(fd)
# Create a simple test image
img = Image.new('RGB', (200, 200), color = (255, 255, 255))
draw = ImageDraw.Draw(img)
draw.rectangle([(20, 20), (180, 180)], fill=(255, 0, 0))
draw.text((40, 100), "Nextcloud MCP Test", fill=(255, 255, 255))
img.save(image_path)
try:
yield image_path
finally:
# Clean up the temporary image file
if os.path.exists(image_path):
os.unlink(image_path)
@pytest.mark.integration
def test_note_with_image_attachment(nc_client: NextcloudClient, test_image):
"""
Test creating a note with an image attachment and properly embedding it
in the note content using Nextcloud Notes' syntax.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Note with Embedded Image {unique_id}"
note_content = "# Note with Embedded Image\n\nThis note contains an embedded image."
note_category = "ImageTests"
created_note = None
note_id = None
try:
# Create the note
print(f"Creating note: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title,
content=note_content,
category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note created with ID: {note_id}")
time.sleep(1)
# Read the test image
with open(test_image, 'rb') as f:
image_content = f.read()
# Attach the image to the note
attachment_filename = f"test_image_{unique_id}.png"
print(f"Attaching image to note {note_id}...")
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=image_content,
mime_type="image/png"
)
assert upload_response["status_code"] in [201, 204]
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# Update the note content to include a reference to the attached image
# Try embedding using Markdown image syntax
updated_content = f"""# Note with Embedded Image
This note contains an embedded image.
## Embedded Image (Markdown Syntax)
![Test Image](.attachments.{note_id}/{attachment_filename})
## WebDAV URL
Files path: `/Notes/.attachments.{note_id}/{attachment_filename}`
"""
# Update the note content
print("Updating note content to include image reference...")
updated_note = nc_client.notes_update_note(
note_id=note_id,
etag=created_note["etag"],
content=updated_content
)
# Retrieve the note to verify content
retrieved_note = nc_client.notes_get_note(note_id=note_id)
print("Retrieved note content:")
print(retrieved_note["content"])
# Verify the image attachment can be retrieved
content, mime_type = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert content == image_content, "Attachment content mismatch"
assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}"
print("Image attachment verified")
finally:
# Cleanup
if note_id:
print(f"Cleaning up - deleting note ID: {note_id}")
try:
nc_client.notes_delete_note(note_id=note_id)
print(f"Note {note_id} deleted")
except Exception as e:
print(f"Error during cleanup: {e}")
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python
import sys
from nextcloud_mcp_server.client import NextcloudClient
def main():
note_id = 420 # ID of the note with the image attachment
# Create client
client = NextcloudClient.from_env()
# First get the current note
try:
note = client.notes_get_note(note_id=note_id)
print(f"Retrieved note: {note['title']}")
# Update the note content to include a direct reference to the image
updated_content = f"""# Note with Image Attachment
This note demonstrates attaching images to Nextcloud Notes.
An image will be attached to this note as a demonstration.
## Image Reference
The image is attached but not displayed inline in the Notes UI.
Attachments in Nextcloud Notes exist as separate files in the .attachments.{note_id}
directory but aren't automatically embedded in the note content.
You can view the image by going to the Files app and navigating to:
/Notes/.attachments.{note_id}/sample_image.png
## Orphaned Attachments
When notes are deleted, their attachments remain in the system.
This is the expected behavior of the official Nextcloud Notes app.
"""
# Update the note with the new content
updated_note = client.notes_update_note(
note_id=note_id,
etag=note['etag'],
content=updated_content
)
print(f"Note updated successfully with image reference information.")
return 0
except Exception as e:
print(f"Error updating note: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python
import os
import sys
from nextcloud_mcp_server.client import NextcloudClient
def main():
note_id = 487 # ID of the note with the issue
# Create client
client = NextcloudClient.from_env()
try:
# Get the current note to get its etag
note = client.notes_get_note(note_id=note_id)
etag = note["etag"]
# Update the note content with correct image reference syntax
updated_content = f"""# Note with Visible Image Demo
This note demonstrates how to properly embed an image in Nextcloud Notes so it's visible in the browser interface.
We'll include the sample red square image we created earlier using both Markdown and HTML methods.
## Method 1: Markdown Image Syntax
![Sample Red Square Image](.attachments.{note_id}/sample_image.png)
## Method 2: HTML Image Tag
<img src=".attachments.{note_id}/sample_image.png" alt="Sample Red Square Image" width="300" />
## Image Path Details
The image is stored at: `/Notes/.attachments.{note_id}/sample_image.png`
## Note on Image Embedding
In Nextcloud Notes, images must be referenced with a period at the beginning of the path. The correct format is:
`.attachments.{note_id}/filename.png`
Without the leading period, the image won't display correctly.
"""
# Update the note with the corrected image references
updated_note = client.notes_update_note(
note_id=note_id,
etag=etag,
content=updated_content
)
print(f"Note updated with corrected image references.")
print(f"Note URL: /index.php/apps/notes/#/note/{note_id}")
return 0
except Exception as e:
print(f"Error: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python
import sys
import os
import base64
from nextcloud_mcp_server.client import NextcloudClient
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def test_webdav_auth():
"""
Test function to verify WebDAV authentication and compare with current implementation.
"""
# Create client using standard method
client = NextcloudClient.from_env()
print("Client authentication type:", type(client._client.auth).__name__)
# Get WebDAV base path
username = os.environ["NEXTCLOUD_USERNAME"]
password = os.environ["NEXTCLOUD_PASSWORD"]
webdav_base = client._get_webdav_base_path()
# Test path for Notes directory
notes_path = f"{webdav_base}/Notes"
print(f"Testing WebDAV access to: {notes_path}")
# 1. Test with existing client auth
try:
print("\nTest 1: Using existing client authentication")
response = client._client.request("PROPFIND", notes_path, headers={"Depth": "0"})
print(f"Status code: {response.status_code}")
if response.status_code >= 400:
print(f"Error: {response.text}")
else:
print("Success! Current auth method works")
except Exception as e:
print(f"Error: {str(e)}")
# 2. Test with explicit Authorization header
try:
print("\nTest 2: Using explicit Authorization header")
# Create base64 encoded credentials
auth_string = f"{username}:{password}"
auth_bytes = auth_string.encode('ascii')
base64_bytes = base64.b64encode(auth_bytes)
base64_auth = base64_bytes.decode('ascii')
# Make request with explicit Authorization header
headers = {
"Depth": "0",
"Authorization": f"Basic {base64_auth}"
}
# Use client without auth to test explicit header
response = client._client.request(
"PROPFIND",
notes_path,
headers=headers,
auth=None # Override client auth
)
print(f"Status code: {response.status_code}")
if response.status_code >= 400:
print(f"Error: {response.text}")
else:
print("Success! Explicit authorization header works")
except Exception as e:
print(f"Error: {str(e)}")
return 0
if __name__ == "__main__":
sys.exit(test_webdav_auth())
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python
import sys
from nextcloud_mcp_server.client import NextcloudClient
def main():
note_id = 420 # ID of the note we created earlier
# Create client
client = NextcloudClient.from_env()
# First verify the note exists
print(f"Retrieving note {note_id}...")
try:
note = client.notes_get_note(note_id=note_id)
print(f"Note retrieved: {note['title']}")
except Exception as e:
print(f"Error retrieving note: {e}")
return 1
# Now try to get the image attachment
attachment_filename = "sample_image.png"
print(f"Retrieving attachment '{attachment_filename}' from note {note_id}...")
try:
content, mime_type = client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print(f"Attachment retrieved successfully!")
print(f"MIME type: {mime_type}")
print(f"Content size: {len(content)} bytes")
# Save the retrieved image to verify it's the same
output_path = "retrieved_image.png"
with open(output_path, 'wb') as f:
f.write(content)
print(f"Saved retrieved image to: {output_path}")
return 0
except Exception as e:
print(f"Error retrieving attachment: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())