diff --git a/README.md b/README.md
index d45e0b0..af8ad55 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/attach_and_embed_image.py b/attach_and_embed_image.py
new file mode 100644
index 0000000..930b900
--- /dev/null
+++ b/attach_and_embed_image.py
@@ -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
+
+
+## Method 2: HTML Image Tag
+
+
+## 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())
diff --git a/attach_image.py b/attach_image.py
new file mode 100644
index 0000000..f9d766c
--- /dev/null
+++ b/attach_image.py
@@ -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())
diff --git a/check_updated_note.py b/check_updated_note.py
new file mode 100644
index 0000000..bcba688
--- /dev/null
+++ b/check_updated_note.py
@@ -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())
diff --git a/docs/nextcloud_notes_image_embedding.md b/docs/nextcloud_notes_image_embedding.md
new file mode 100644
index 0000000..6fe2e70
--- /dev/null
+++ b/docs/nextcloud_notes_image_embedding.md
@@ -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
+
+```
+
+For example:
+```markdown
+
+```
+
+### 2. HTML Image Tags
+
+```html
+
+```
+
+For example:
+```html
+
+```
+
+## 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:
+
+
+"""
+
+# 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
diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py
index f065a43..e143485 100644
--- a/nextcloud_mcp_server/client.py
+++ b/nextcloud_mcp_server/client.py
@@ -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
diff --git a/nextcloud_mcp_server/server.py b/nextcloud_mcp_server/server.py
index 0e7b142..740fdd2 100644
--- a/nextcloud_mcp_server/server.py
+++ b/nextcloud_mcp_server/server.py
@@ -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()
diff --git a/poetry.lock b/poetry.lock
index 4038539..209e9f0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -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"
diff --git a/pyproject.toml b/pyproject.toml
index 59124a0..646e43e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/retrieved_image.png b/retrieved_image.png
new file mode 100644
index 0000000..fd67dba
Binary files /dev/null and b/retrieved_image.png differ
diff --git a/sample_image.png b/sample_image.png
new file mode 100644
index 0000000..fd67dba
Binary files /dev/null and b/sample_image.png differ
diff --git a/sample_image.py b/sample_image.py
new file mode 100644
index 0000000..b3e0139
--- /dev/null
+++ b/sample_image.py
@@ -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")
diff --git a/test_delete_note_with_attachment.py b/test_delete_note_with_attachment.py
new file mode 100644
index 0000000..e591cfc
--- /dev/null
+++ b/test_delete_note_with_attachment.py
@@ -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())
diff --git a/tests/test_client.py b/tests/test_client.py
index 8d41cee..a8a576e 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -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}")
diff --git a/tests/test_embedded_images.py b/tests/test_embedded_images.py
new file mode 100644
index 0000000..6246164
--- /dev/null
+++ b/tests/test_embedded_images.py
@@ -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
+
+
+## Method 2: HTML Image Tag
+
+
+## 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)
diff --git a/tests/test_note_attachment_cleanup.py b/tests/test_note_attachment_cleanup.py
new file mode 100644
index 0000000..cf8f2cf
--- /dev/null
+++ b/tests/test_note_attachment_cleanup.py
@@ -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
diff --git a/tests/test_note_image_integration.py b/tests/test_note_image_integration.py
new file mode 100644
index 0000000..26225ce
--- /dev/null
+++ b/tests/test_note_image_integration.py
@@ -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)
+
+
+## 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}")
diff --git a/update_content_with_image_reference.py b/update_content_with_image_reference.py
new file mode 100644
index 0000000..f5c4efc
--- /dev/null
+++ b/update_content_with_image_reference.py
@@ -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())
diff --git a/update_image_reference.py b/update_image_reference.py
new file mode 100644
index 0000000..80f6d84
--- /dev/null
+++ b/update_image_reference.py
@@ -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
+
+
+## Method 2: HTML Image Tag
+
+
+## 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())
diff --git a/update_webdav_auth.py b/update_webdav_auth.py
new file mode 100644
index 0000000..0be4673
--- /dev/null
+++ b/update_webdav_auth.py
@@ -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())
diff --git a/verify_image_attachment.py b/verify_image_attachment.py
new file mode 100644
index 0000000..281f1b2
--- /dev/null
+++ b/verify_image_attachment.py
@@ -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())