diff --git a/README.md b/README.md index c57c507..0dfa5d9 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,46 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models ( ## Features -Currently, the server primarily interacts with the Nextcloud Notes API, providing tools and resources to manage notes. +The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources. -### Available Tools +## Supported Nextcloud Apps -* `nc_notes_create_note`: Create a new note. -* `nc_notes_update_note`: Update an existing note by ID. -* `nc_notes_append_content`: Append content to an existing note with a clear separator. -* `nc_notes_delete_note`: Delete a note by ID. -* `nc_notes_search_notes`: Search notes by title or content. -* `nc_get_note`: Get a specific note by ID. +| App | Support Status | Description | +|-----|----------------|-------------| +| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. | +| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. | -### Available Resources +## Available Tools -* `notes://{note_id}`: Access a specific note by its ID. -* `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. +### Notes Tools + +| Tool | Description | +|------|-------------| +| `nc_get_note` | Get a specific note by ID | +| `nc_notes_create_note` | Create a new note with title, content, and category | +| `nc_notes_update_note` | Update an existing note by ID | +| `nc_notes_append_content` | Append content to an existing note with a clear separator | +| `nc_notes_delete_note` | Delete a note by ID | +| `nc_notes_search_notes` | Search notes by title or content | + +### Tables Tools + +| Tool | Description | +|------|-------------| +| `nc_tables_list_tables` | List all tables available to the user | +| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views | +| `nc_tables_read_table` | Read rows from a table with optional pagination | +| `nc_tables_insert_row` | Insert a new row into a table | +| `nc_tables_update_row` | Update an existing row in a table | +| `nc_tables_delete_row` | Delete a row from a table | + +## Available Resources + +| Resource | Description | +|----------|-------------| +| `nc://capabilities` | Access Nextcloud server capabilities | +| `notes://settings` | Access Notes app settings | +| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes | ### Note Attachments diff --git a/nextcloud_mcp_server/server.py b/nextcloud_mcp_server/server.py index 32cf3d7..a5edfbd 100644 --- a/nextcloud_mcp_server/server.py +++ b/nextcloud_mcp_server/server.py @@ -38,7 +38,6 @@ logger = logging.getLogger(__name__) @mcp.resource("nc://capabilities") async def nc_get_capabilities(): """Get the Nextcloud Host capabilities""" - # client = NextcloudClient.from_env() ctx = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 diff --git a/tests/conftest.py b/tests/conftest.py index c166b40..3664720 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,7 +52,7 @@ async def temporary_note(nc_client: NextcloudClient): logger.info(f"Creating temporary note: {note_title}") try: - created_note_data = await nc_client.notes_create_note( + created_note_data = await nc_client.notes.create_note( title=note_title, content=note_content, category=note_category ) note_id = created_note_data.get("id") @@ -66,7 +66,7 @@ async def temporary_note(nc_client: NextcloudClient): if note_id: logger.info(f"Cleaning up temporary note ID: {note_id}") try: - await nc_client.notes_delete_note(note_id=note_id) + await nc_client.notes.delete_note(note_id=note_id) logger.info(f"Successfully deleted temporary note ID: {note_id}") except HTTPStatusError as e: # Ignore 404 if note was already deleted by the test itself @@ -102,7 +102,7 @@ async def temporary_note_with_attachment( ) try: # Pass the category to add_note_attachment - upload_response = await nc_client.add_note_attachment( + upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, filename=attachment_filename, content=attachment_content, diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py index 870a23f..a489583 100644 --- a/tests/integration/test_attachments.py +++ b/tests/integration/test_attachments.py @@ -29,7 +29,7 @@ async def test_attachments_add_and_get( f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}" ) # Pass category to get_note_attachment - retrieved_content, retrieved_mime = await nc_client.get_note_attachment( + retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=note_category ) logger.info( @@ -67,7 +67,7 @@ async def test_attachments_add_to_note_with_category( f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}" ) # Pass category to add_note_attachment - upload_response = await nc_client.add_note_attachment( + upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, filename=attachment_filename, content=attachment_content, @@ -86,7 +86,7 @@ async def test_attachments_add_to_note_with_category( f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}" ) # Pass category to get_note_attachment - retrieved_content, retrieved_mime = await nc_client.get_note_attachment( + retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=note_category, # Pass the note's category @@ -127,13 +127,13 @@ async def test_attachments_cleanup_on_note_delete( # Manually delete the note logger.info(f"Manually deleting note ID: {note_id} within the test.") - await nc_client.notes_delete_note(note_id=note_id) + await nc_client.notes.delete_note(note_id=note_id) logger.info(f"Note ID: {note_id} deleted successfully.") time.sleep(1) # Verify Note Is Deleted with pytest.raises(HTTPStatusError) as excinfo_note: - await nc_client.notes_get_note(note_id=note_id) + await nc_client.notes.get_note(note_id=note_id) assert excinfo_note.value.response.status_code == 404 logger.info(f"Verified note {note_id} deletion (404 received).") @@ -145,7 +145,7 @@ async def test_attachments_cleanup_on_note_delete( # Pass category to get_note_attachment - although it should fail anyway # because the note (and thus details) are gone. # The client method will raise 404 from the initial notes_get_note call. - await nc_client.get_note_attachment( + await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=note_category, # Pass category, though note fetch should fail first @@ -205,7 +205,7 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): try: # 1. Create note with initial category logger.info(f"Creating note '{note_title}' in category '{initial_category}'") - created_note = await nc_client.notes_create_note( + created_note = await nc_client.notes.create_note( title=note_title, content="Initial content", category=initial_category ) note_id = created_note["id"] @@ -217,7 +217,7 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): logger.info( f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})" ) - upload_response = await nc_client.add_note_attachment( + upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, filename=attachment_filename, content=attachment_content, @@ -232,7 +232,7 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): logger.info( f"Verifying attachment retrieval from initial category '{initial_category}'" ) - retrieved_content1, _ = await nc_client.get_note_attachment( + retrieved_content1, _ = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=initial_category ) assert retrieved_content1 == attachment_content @@ -243,9 +243,9 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): f"Updating note {note_id} category from '{initial_category}' to '{new_category}'" ) # Need to fetch the latest etag after attachment add (WebDAV ops don't update note etag) - current_note_data = await nc_client.notes_get_note(note_id=note_id) + current_note_data = await nc_client.notes.get_note(note_id=note_id) current_etag = current_note_data["etag"] - updated_note = await nc_client.notes_update_note( + updated_note = await nc_client.notes.update( note_id=note_id, etag=current_etag, category=new_category, @@ -261,7 +261,7 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): logger.info( f"Verifying attachment retrieval from new category '{new_category}'" ) - retrieved_content2, _ = await nc_client.get_note_attachment( + retrieved_content2, _ = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=new_category ) assert retrieved_content2 == attachment_content @@ -326,18 +326,18 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient): f"Cleaning up note ID: {note_id} (last known category: '{new_category}')" ) try: - await nc_client.notes_delete_note(note_id=note_id) + await nc_client.notes.delete_note(note_id=note_id) logger.info(f"Note {note_id} deleted.") time.sleep(1) # Verify note deletion with pytest.raises(HTTPStatusError) as excinfo_note_del: - await nc_client.notes_get_note(note_id=note_id) + await nc_client.notes.get_note(note_id=note_id) assert excinfo_note_del.value.response.status_code == 404 logger.info("Verified note deleted (404).") # Verify attachment deletion (should fail with 404 on the initial note fetch) with pytest.raises(HTTPStatusError) as excinfo_attach_del: # Pass the *last known* category, although the note fetch should fail first - await nc_client.get_note_attachment( + await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=new_category, diff --git a/tests/integration/test_embedded_images.py b/tests/integration/test_embedded_images.py index fc90f38..6ab1fa0 100644 --- a/tests/integration/test_embedded_images.py +++ b/tests/integration/test_embedded_images.py @@ -62,7 +62,7 @@ async def test_note_with_embedded_image( logger.info( f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..." ) - upload_response = await nc_client.add_note_attachment( + upload_response = await nc_client.webdav.add_note_attachment( note_id=note_id, filename=attachment_filename, content=image_content, @@ -115,7 +115,7 @@ async def test_note_with_embedded_image( Test Image HTML """ logger.info("Updating note content with image references...") - updated_note = await nc_client.notes_update_note( + updated_note = await nc_client.notes.update( note_id=note_id, etag=note_etag, # Use etag from the created note content=updated_content, @@ -128,7 +128,7 @@ async def test_note_with_embedded_image( time.sleep(1) # 3. Verify the updated note content - retrieved_note = await nc_client.notes_get_note(note_id=note_id) + retrieved_note = await nc_client.notes.get_note(note_id=note_id) assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"] logger.info("Verified image reference exists in updated note content.") @@ -137,7 +137,7 @@ async def test_note_with_embedded_image( f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..." ) # Pass category to get_note_attachment - retrieved_img_content, mime_type = await nc_client.get_note_attachment( + retrieved_img_content, mime_type = await nc_client.webdav.get_note_attachment( note_id=note_id, filename=attachment_filename, category=note_category ) assert retrieved_img_content == image_content @@ -150,13 +150,13 @@ async def test_note_with_embedded_image( logger.info( f"Manually deleting note ID: {note_id} to verify proper attachment cleanup" ) - await nc_client.notes_delete_note(note_id=note_id) + await nc_client.notes.delete_note(note_id=note_id) logger.info(f"Note ID: {note_id} deleted successfully.") time.sleep(1) # 6. Verify note is deleted with pytest.raises(HTTPStatusError) as excinfo_note: - await nc_client.notes_get_note(note_id=note_id) + await nc_client.notes.get_note(note_id=note_id) assert excinfo_note.value.response.status_code == 404 logger.info(f"Verified note {note_id} deletion (404 received).") diff --git a/tests/integration/test_notes_api.py b/tests/integration/test_notes_api.py index 5263e6e..7ffd9e9 100644 --- a/tests/integration/test_notes_api.py +++ b/tests/integration/test_notes_api.py @@ -24,7 +24,7 @@ async def test_notes_api_create_and_read( note_id = created_note_data["id"] logger.info(f"Reading note created by fixture, ID: {note_id}") - read_note = await nc_client.notes_get_note(note_id=note_id) + read_note = await nc_client.notes.get_note(note_id=note_id) assert read_note["id"] == note_id assert read_note["title"] == created_note_data["title"] @@ -46,7 +46,7 @@ async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict update_content = f"Updated Content {uuid.uuid4().hex[:8]}" logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}") - updated_note = await nc_client.notes_update_note( + updated_note = await nc_client.notes.update( note_id=note_id, etag=original_etag, title=update_title, @@ -66,7 +66,7 @@ async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict # Optional: Verify update by reading again await asyncio.sleep(1) # Allow potential propagation delay - read_updated_note = await nc_client.notes_get_note(note_id=note_id) + read_updated_note = await nc_client.notes.get_note(note_id=note_id) assert read_updated_note["title"] == update_title assert read_updated_note["content"] == update_content logger.info(f"Successfully updated and verified note ID: {note_id}") @@ -85,7 +85,7 @@ async def test_notes_api_update_conflict( # Perform a first update to change the etag first_update_title = f"First Update {uuid.uuid4().hex[:8]}" logger.info(f"Performing first update on note ID: {note_id} to change etag.") - first_updated_note = await nc_client.notes_update_note( + first_updated_note = await nc_client.notes.update( note_id=note_id, etag=original_etag, title=first_update_title, @@ -102,7 +102,7 @@ async def test_notes_api_update_conflict( f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}" ) with pytest.raises(HTTPStatusError) as excinfo: - await nc_client.notes_update_note( + await nc_client.notes.update( note_id=note_id, etag=original_etag, # Use the stale etag title="This update should fail due to conflict", @@ -119,7 +119,7 @@ async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient): non_existent_id = 999999999 # Use an ID highly unlikely to exist logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}") with pytest.raises(HTTPStatusError) as excinfo: - await nc_client.notes_delete_note(note_id=non_existent_id) + await nc_client.notes.delete_note(note_id=non_existent_id) assert excinfo.value.response.status_code == 404 logger.info( f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404." @@ -139,7 +139,7 @@ async def test_notes_api_append_content_to_existing_note( append_text = f"Appended content {uuid.uuid4().hex[:8]}" logger.info(f"Appending content to note ID: {note_id}") - updated_note = await nc_client.notes_append_content( + updated_note = await nc_client.notes.append_content( note_id=note_id, content=append_text ) logger.info(f"Note after append: {updated_note}") @@ -155,7 +155,7 @@ async def test_notes_api_append_content_to_existing_note( # Verify by reading the note again await asyncio.sleep(1) # Allow potential propagation delay - read_note = await nc_client.notes_get_note(note_id=note_id) + read_note = await nc_client.notes.get_note(note_id=note_id) assert read_note["content"] == expected_content logger.info(f"Successfully appended content to note ID: {note_id}") @@ -169,7 +169,7 @@ async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient test_category = "Test" logger.info("Creating empty note for append test") - empty_note = await nc_client.notes_create_note( + empty_note = await nc_client.notes.create_note( title=test_title, content="", category=test_category, # Empty content @@ -180,7 +180,7 @@ async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient append_text = f"First content {uuid.uuid4().hex[:8]}" logger.info(f"Appending content to empty note ID: {note_id}") - updated_note = await nc_client.notes_append_content( + updated_note = await nc_client.notes.append_content( note_id=note_id, content=append_text ) @@ -189,14 +189,14 @@ async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient # Verify by reading the note again await asyncio.sleep(1) - read_note = await nc_client.notes_get_note(note_id=note_id) + read_note = await nc_client.notes.get_note(note_id=note_id) assert read_note["content"] == append_text logger.info(f"Successfully appended content to empty note ID: {note_id}") finally: # Clean up the test note try: - await nc_client.notes_delete_note(note_id=note_id) + await nc_client.notes.delete_note(note_id=note_id) logger.info(f"Cleaned up test note ID: {note_id}") except Exception as e: logger.warning(f"Failed to clean up test note ID: {note_id}: {e}") @@ -218,7 +218,7 @@ async def test_notes_api_append_content_multiple_times( logger.info(f"Performing multiple appends to note ID: {note_id}") # First append - updated_note = await nc_client.notes_append_content( + updated_note = await nc_client.notes.append_content( note_id=note_id, content=first_append ) @@ -226,7 +226,7 @@ async def test_notes_api_append_content_multiple_times( assert updated_note["content"] == expected_content_after_first # Second append - updated_note = await nc_client.notes_append_content( + updated_note = await nc_client.notes.append_content( note_id=note_id, content=second_append ) @@ -237,7 +237,7 @@ async def test_notes_api_append_content_multiple_times( # Verify by reading the note again await asyncio.sleep(1) - read_note = await nc_client.notes_get_note(note_id=note_id) + read_note = await nc_client.notes.get_note(note_id=note_id) assert read_note["content"] == expected_content_after_second logger.info(f"Successfully performed multiple appends to note ID: {note_id}") @@ -250,13 +250,10 @@ async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudCli logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}") with pytest.raises(HTTPStatusError) as excinfo: - await nc_client.notes_append_content( + await nc_client.notes.append_content( note_id=non_existent_id, content="This should fail" ) assert excinfo.value.response.status_code == 404 logger.info( f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404." ) - - -# --- Attachment tests moved to test_attachments.py ---