Skip to main content
Glama
test_mcp.py31.7 kB
import json import logging import uuid import pytest from mcp import ClientSession from nextcloud_mcp_server.client import NextcloudClient logger = logging.getLogger(__name__) pytestmark = pytest.mark.integration async def test_mcp_connectivity(nc_mcp_client: ClientSession): """Test basic MCP server connectivity and list available tools/resources.""" # List available tools tools = await nc_mcp_client.list_tools() logger.info("Available MCP tools:") tool_names = [] for tool in tools.tools: logger.info(f" - {tool.name}: {tool.description}") tool_names.append(tool.name) # Verify expected tools are present expected_tools = [ "nc_notes_create_note", "nc_notes_update_note", "nc_notes_append_content", "nc_notes_search_notes", "nc_notes_delete_note", "nc_tables_list_tables", "nc_tables_get_schema", "nc_tables_read_table", "nc_tables_insert_row", "nc_tables_update_row", "nc_tables_delete_row", "nc_webdav_list_directory", "nc_webdav_read_file", "nc_webdav_write_file", "nc_webdav_create_directory", "nc_webdav_delete_resource", "nc_webdav_move_resource", "nc_webdav_copy_resource", "nc_webdav_search_files", "nc_webdav_find_by_name", "nc_webdav_find_by_type", "nc_webdav_list_favorites", "nc_calendar_list_calendars", "nc_calendar_create_event", "nc_calendar_list_events", "nc_calendar_get_event", "nc_calendar_update_event", "nc_calendar_delete_event", "nc_calendar_create_meeting", "nc_calendar_get_upcoming_events", "nc_calendar_find_availability", "nc_calendar_bulk_operations", "nc_calendar_manage_calendar", "nc_calendar_list_todos", "nc_calendar_create_todo", "nc_calendar_update_todo", "nc_calendar_delete_todo", "nc_calendar_search_todos", "deck_create_board", "nc_cookbook_import_recipe", "nc_cookbook_list_recipes", "nc_cookbook_get_recipe", "nc_cookbook_create_recipe", "nc_cookbook_update_recipe", "nc_cookbook_delete_recipe", "nc_cookbook_search_recipes", "nc_cookbook_list_categories", "nc_cookbook_get_recipes_in_category", "nc_cookbook_list_keywords", "nc_cookbook_get_recipes_with_keywords", "nc_cookbook_set_config", "nc_cookbook_reindex", ] for expected_tool in expected_tools: assert expected_tool in tool_names, ( f"Expected tool '{expected_tool}' not found in available tools" ) # List available resource templates templates = await nc_mcp_client.list_resource_templates() logger.info("\nAvailable resource templates:") template_uris = [] for template in templates.resourceTemplates: logger.info(f" - {template.uriTemplate}") template_uris.append(template.uriTemplate) # Verify expected resource templates # Note: Notes attachments are now handled via tools, not resource templates expected_templates = [] for expected_template in expected_templates: assert expected_template in template_uris, ( f"Expected template '{expected_template}' not found" ) # List available resources resources = await nc_mcp_client.list_resources() logger.info("\nAvailable resources:") resource_uris = [] for resource in resources.resources: logger.info(f" - {resource.uri}: {resource.name}") resource_uris.append(str(resource.uri)) # Convert to string for comparison # Verify expected resources expected_resources = [ "nc://capabilities", "notes://settings", "nc://Deck/boards", "cookbook://version", "cookbook://config", ] for expected_resource in expected_resources: assert expected_resource in resource_uris, ( f"Expected resource '{expected_resource}' not found" ) # List available prompts prompts = await nc_mcp_client.list_prompts() logger.info("\nAvailable prompts:") for prompt in prompts.prompts: logger.info(f" - {prompt.name}") async def test_mcp_notes_crud_workflow( nc_mcp_client: ClientSession, nc_client: NextcloudClient ): """Test complete Notes CRUD workflow via MCP tools with verification via NextcloudClient.""" unique_suffix = uuid.uuid4().hex[:8] test_title = f"MCP Test Note {unique_suffix}" test_content = f"This is test content for note {unique_suffix}" test_category = "MCPTesting" created_note = None try: # 1. Create note via MCP logger.info(f"Creating note via MCP: {test_title}") create_result = await nc_mcp_client.call_tool( "nc_notes_create_note", {"title": test_title, "content": test_content, "category": test_category}, ) assert create_result.isError is False, ( f"MCP note creation failed: {create_result.content}" ) created_note = create_result.content[0].text note_data = json.loads(created_note) # Parse the returned JSON note_id = note_data["id"] create_etag = note_data["etag"] # Verify create response includes ETag logger.info(f"Note created via MCP with ID: {note_id}, ETag: {create_etag}") assert "etag" in note_data, "Create response should include ETag" assert create_etag, "Create ETag should not be empty" # 2. Verify creation via direct NextcloudClient direct_note = await nc_client.notes.get_note(note_id) assert direct_note["title"] == test_title, ( f"Title mismatch: {direct_note['title']} != {test_title}" ) assert direct_note["content"] == test_content, "Content mismatch" assert direct_note["category"] == test_category, "Category mismatch" # 3. Read note via MCP logger.info(f"Reading note via MCP: {note_id}") read_result = await nc_mcp_client.call_tool( "nc_notes_get_note", {"note_id": note_id} ) read_note_data = read_result.content[0].text read_note_data = json.loads(read_note_data) assert read_note_data["title"] == test_title assert read_note_data["content"] == test_content assert read_note_data["category"] == test_category # 4. Update note via MCP updated_title = f"Updated {test_title}" updated_content = f"Updated content: {test_content}" etag = read_note_data["etag"] logger.info(f"Updating note via MCP: {note_id}") update_result = await nc_mcp_client.call_tool( "nc_notes_update_note", { "note_id": note_id, "etag": etag, "title": updated_title, "content": updated_content, "category": test_category, }, ) assert update_result.isError is False, ( f"MCP note update failed: {update_result.content}" ) # Verify update response includes new ETag updated_note_data = json.loads(update_result.content[0].text) update_etag = updated_note_data["etag"] logger.info(f"Note updated via MCP, new ETag: {update_etag}") assert "etag" in updated_note_data, "Update response should include ETag" assert update_etag, "Update ETag should not be empty" assert update_etag != etag, "ETag should change after update" # 5. Verify update via direct NextcloudClient updated_direct_note = await nc_client.notes.get_note(note_id) assert updated_direct_note["title"] == updated_title assert updated_direct_note["content"] == updated_content # 6. Append content via MCP append_content = "\n\nThis is appended content via MCP." logger.info(f"Appending content to note via MCP: {note_id}") append_result = await nc_mcp_client.call_tool( "nc_notes_append_content", {"note_id": note_id, "content": append_content} ) assert append_result.isError is False, ( f"MCP note append failed: {append_result.content}" ) # Verify append response includes new ETag appended_note_data = json.loads(append_result.content[0].text) append_etag = appended_note_data["etag"] logger.info(f"Content appended via MCP, new ETag: {append_etag}") assert "etag" in appended_note_data, "Append response should include ETag" assert append_etag, "Append ETag should not be empty" assert append_etag != update_etag, "ETag should change after append" # 7. Verify append via direct NextcloudClient appended_direct_note = await nc_client.notes.get_note(note_id) assert append_content in appended_direct_note["content"] # 8. Search for note via MCP logger.info(f"Searching for note via MCP with query: {unique_suffix}") search_result = await nc_mcp_client.call_tool( "nc_notes_search_notes", {"query": unique_suffix} ) assert search_result.isError is False, ( f"MCP note search failed: {search_result.content}" ) search_notes_text = search_result.content[0].text logger.info(f"Search result text: {search_notes_text}") search_response = json.loads(search_notes_text) # Expect structured response with Pydantic format assert isinstance(search_response, dict), ( f"Expected search response to be a dict with structured format, got: {type(search_response)}" ) assert "results" in search_response, ( f"Expected 'results' field in search response, got keys: {list(search_response.keys())}" ) assert "success" in search_response and search_response["success"], ( f"Expected successful search response, got: {search_response}" ) search_notes = search_response["results"] assert isinstance(search_notes, list), ( f"Expected results to be a list, got: {type(search_notes)}" ) # Find our note in search results found_note = None for note in search_notes: if isinstance(note, dict) and note.get("id") == note_id: found_note = note break assert found_note is not None, ( f"Created note not found in search results. Search returned: {search_response}" ) assert found_note["title"] == updated_title # 9. Delete note via MCP logger.info(f"Deleting note via MCP: {note_id}") delete_result = await nc_mcp_client.call_tool( "nc_notes_delete_note", {"note_id": note_id} ) assert delete_result.isError is False, ( f"MCP note deletion failed: {delete_result.content}" ) # 10. Verify deletion via direct NextcloudClient try: await nc_client.notes.get_note(note_id) pytest.fail("Note should have been deleted but was still found") except Exception: # Expected - note should be deleted logger.info(f"Successfully verified note {note_id} was deleted") created_note = None # Mark as cleaned up finally: # Cleanup in case of test failure if created_note is not None: try: note_data = json.loads(created_note) await nc_client.notes.delete_note(note_data["id"]) logger.info(f"Cleaned up note {note_data['id']} after test failure") except Exception as e: logger.warning(f"Failed to cleanup note: {e}") async def test_mcp_notes_etag_conflict( nc_mcp_client: ClientSession, nc_client: NextcloudClient ): """Test that MCP note updates fail when using stale ETags.""" unique_suffix = uuid.uuid4().hex[:8] test_title = f"ETag Test Note {unique_suffix}" test_content = f"This is test content for ETag testing {unique_suffix}" test_category = "ETTesting" created_note = None try: # 1. Create note via MCP logger.info(f"Creating note for ETag conflict test: {test_title}") create_result = await nc_mcp_client.call_tool( "nc_notes_create_note", {"title": test_title, "content": test_content, "category": test_category}, ) assert create_result.isError is False note_data = json.loads(create_result.content[0].text) note_id = note_data["id"] original_etag = note_data["etag"] created_note = note_data # 2. Update note to change ETag first_update_result = await nc_mcp_client.call_tool( "nc_notes_update_note", { "note_id": note_id, "etag": original_etag, "title": f"First Update {test_title}", "content": test_content, "category": test_category, }, ) assert first_update_result.isError is False updated_data = json.loads(first_update_result.content[0].text) new_etag = updated_data["etag"] assert new_etag != original_etag, "ETag should have changed after update" # 3. Try to update with the stale (original) ETag - this should fail logger.info(f"Attempting update with stale ETag: {original_etag}") conflict_result = await nc_mcp_client.call_tool( "nc_notes_update_note", { "note_id": note_id, "etag": original_etag, # Use stale ETag "title": "This should fail", "content": "This update should be rejected", "category": test_category, }, ) # 4. Verify the update was rejected with a 412 error # With McpError, tools now return proper error responses assert conflict_result.isError is True, "Update with stale ETag should fail" response_content = conflict_result.content[0].text assert "modified by someone else" in response_content, ( f"Expected conflict error message, got: {response_content}" ) logger.info("Successfully verified ETag conflict handling via MCP") finally: # Cleanup if created_note is not None: try: await nc_client.notes.delete_note(created_note["id"]) logger.info(f"Cleaned up test note {created_note['id']}") except Exception as e: logger.warning(f"Failed to cleanup test note: {e}") async def test_mcp_webdav_workflow( nc_mcp_client: ClientSession, nc_client: NextcloudClient ): """Test WebDAV file operations via MCP tools with verification via NextcloudClient.""" unique_suffix = uuid.uuid4().hex[:8] test_dir = f"mcp_test_dir_{unique_suffix}" test_file = f"mcp_test_file_{unique_suffix}.txt" test_file_path = f"{test_dir}/{test_file}" test_content = f"This is test content for MCP WebDAV testing {unique_suffix}" try: # 1. Create directory via MCP logger.info(f"Creating directory via MCP: {test_dir}") create_dir_result = await nc_mcp_client.call_tool( "nc_webdav_create_directory", {"path": test_dir} ) assert create_dir_result.isError is False, ( f"MCP directory creation failed: {create_dir_result.content}" ) # 2. Verify directory creation via direct WebDAV dir_listing = await nc_client.webdav.list_directory("") dir_names = [item["name"] for item in dir_listing if item["is_directory"]] assert test_dir in dir_names, f"Directory {test_dir} not found in root listing" # 3. Write file via MCP logger.info(f"Writing file via MCP: {test_file_path}") write_result = await nc_mcp_client.call_tool( "nc_webdav_write_file", { "path": test_file_path, "content": test_content, "content_type": "text/plain", }, ) assert write_result.isError is False, ( f"MCP file write failed: {write_result.content}" ) # 4. Verify file creation via direct WebDAV file_listing = await nc_client.webdav.list_directory(test_dir) file_names = [item["name"] for item in file_listing if not item["is_directory"]] assert test_file in file_names, ( f"File {test_file} not found in directory listing" ) # 5. Read file via MCP logger.info(f"Reading file via MCP: {test_file_path}") read_result = await nc_mcp_client.call_tool( "nc_webdav_read_file", {"path": test_file_path} ) assert read_result.isError is False, ( f"MCP file read failed: {read_result.content}" ) read_data = json.loads(read_result.content[0].text) assert read_data["content"] == test_content, "File content mismatch" assert read_data["path"] == test_file_path assert "text/plain" in read_data["content_type"] # 6. Verify file content via direct WebDAV direct_content, direct_content_type = await nc_client.webdav.read_file( test_file_path ) assert direct_content.decode("utf-8") == test_content # 7. List directory via MCP logger.info(f"Listing directory via MCP: {test_dir}") list_result = await nc_mcp_client.call_tool( "nc_webdav_list_directory", {"path": test_dir} ) assert list_result.isError is False, ( f"MCP directory listing failed: {list_result.content}" ) listing_text = list_result.content[0].text logger.info(f"Directory listing response: {listing_text}") listing_data = json.loads(listing_text) # Extract files from DirectoryListing response assert "files" in listing_data, "Expected 'files' field in directory listing" files = listing_data["files"] assert isinstance(files, list), ( f"Expected files to be a list, got: {type(files)}" ) # Verify metadata assert listing_data["path"] == test_dir assert listing_data["total_count"] >= 1 assert listing_data["files_count"] >= 1 # Find our file in the listing found_file = None for item in files: if isinstance(item, dict) and item.get("name") == test_file: found_file = item break assert found_file is not None, ( f"File {test_file} not found in MCP directory listing" ) assert found_file["is_directory"] is False assert found_file["size"] == len(test_content.encode("utf-8")) finally: # Cleanup try: logger.info(f"Cleaning up test file: {test_file_path}") await nc_mcp_client.call_tool( "nc_webdav_delete_resource", {"path": test_file_path} ) logger.info(f"Cleaning up test directory: {test_dir}") await nc_mcp_client.call_tool( "nc_webdav_delete_resource", {"path": test_dir} ) except Exception as e: logger.warning(f"Failed to cleanup WebDAV resources: {e}") async def test_mcp_resources_access( nc_mcp_client: ClientSession, nc_client: NextcloudClient ): """Test accessing MCP resources and compare with direct API calls.""" # 1. Test capabilities resource logger.info("Testing capabilities resource via MCP") caps_result = await nc_mcp_client.read_resource("nc://capabilities") assert len(caps_result.contents) == 1 mcp_capabilities = json.loads(caps_result.contents[0].text) # Compare with direct API call direct_capabilities = await nc_client.capabilities() # Basic validation - both should have similar structure # Both return full OCS response structure assert "ocs" in mcp_capabilities assert "data" in mcp_capabilities["ocs"] assert "version" in mcp_capabilities["ocs"]["data"] assert "ocs" in direct_capabilities assert "data" in direct_capabilities["ocs"] assert "version" in direct_capabilities["ocs"]["data"] # 2. Test notes settings resource logger.info("Testing notes settings resource via MCP") settings_result = await nc_mcp_client.read_resource("notes://settings") assert len(settings_result.contents) == 1 mcp_settings = json.loads(settings_result.contents[0].text) # Compare with direct API call direct_settings = await nc_client.notes.get_settings() # Both should have settings data assert isinstance(mcp_settings, dict) assert isinstance(direct_settings, dict) logger.info("Successfully verified MCP resources match direct API calls") async def test_mcp_calendar_workflow( nc_mcp_client: ClientSession, nc_client: NextcloudClient ): """Test complete Calendar workflow via MCP tools with verification via NextcloudClient.""" unique_suffix = uuid.uuid4().hex[:8] test_event_title = f"MCP Test Event {unique_suffix}" test_location = f"MCP Test Location {unique_suffix}" created_event = None calendar_name = None try: # 1. List calendars via MCP logger.info("Listing calendars via MCP") calendars_result = await nc_mcp_client.call_tool( "nc_calendar_list_calendars", {} ) assert calendars_result.isError is False, ( f"MCP calendar listing failed: {calendars_result.content}" ) calendars_response = json.loads(calendars_result.content[0].text) # Debug output to understand the structure logger.info(f"calendars_response type: {type(calendars_response)}") logger.info(f"calendars_response content: {calendars_response}") # Expect structured response with Pydantic format assert isinstance(calendars_response, dict), ( f"Expected calendar response to be a dict with structured format, got: {type(calendars_response)}" ) assert "calendars" in calendars_response, ( f"Expected 'calendars' field in response, got keys: {list(calendars_response.keys())}" ) assert "success" in calendars_response and calendars_response["success"], ( f"Expected successful calendar response, got: {calendars_response}" ) calendars_list = calendars_response["calendars"] assert isinstance(calendars_list, list), ( f"Expected calendars to be a list, got: {type(calendars_list)}" ) if not calendars_list: pytest.skip("No calendars available for testing") # Use the first available calendar calendar_name = calendars_list[0]["name"] logger.info(f"Using calendar: {calendar_name}") # 2. Create event via MCP from datetime import datetime, timedelta tomorrow = datetime.now() + timedelta(days=1) start_datetime = tomorrow.strftime("%Y-%m-%dT14:00:00") end_datetime = tomorrow.strftime("%Y-%m-%dT15:00:00") event_data = { "calendar_name": calendar_name, "title": test_event_title, "start_datetime": start_datetime, "end_datetime": end_datetime, "description": f"Test event created via MCP {unique_suffix}", "location": test_location, "categories": "testing,mcp", "status": "CONFIRMED", "priority": 5, } logger.info(f"Creating event via MCP: {test_event_title}") create_result = await nc_mcp_client.call_tool( "nc_calendar_create_event", event_data ) assert create_result.isError is False, ( f"MCP event creation failed: {create_result.content}" ) created_event_data = json.loads(create_result.content[0].text) event_uid = created_event_data["uid"] created_event = {"uid": event_uid, "calendar_name": calendar_name} logger.info(f"Event created via MCP with UID: {event_uid}") # 3. Verify creation via direct NextcloudClient direct_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid) assert direct_event["title"] == test_event_title assert direct_event["location"] == test_location assert "testing" in direct_event.get("categories", "") # 4. Get event via MCP logger.info(f"Getting event via MCP: {event_uid}") get_result = await nc_mcp_client.call_tool( "nc_calendar_get_event", {"calendar_name": calendar_name, "event_uid": event_uid}, ) assert get_result.isError is False, ( f"MCP event get failed: {get_result.content}" ) get_event_data = json.loads(get_result.content[0].text) assert get_event_data["title"] == test_event_title assert get_event_data["location"] == test_location # 5. **TEST nc_calendar_list_events - This is the main tool we're testing** logger.info("Testing nc_calendar_list_events via MCP") # Get today and next week for date range today = datetime.now() next_week = today + timedelta(days=7) start_date = today.strftime("%Y-%m-%d") end_date = next_week.strftime("%Y-%m-%d") list_events_data = { "calendar_name": calendar_name, "start_date": start_date, "end_date": end_date, "limit": 50, "location_contains": "MCP Test", "title_contains": unique_suffix, } list_result = await nc_mcp_client.call_tool( "nc_calendar_list_events", list_events_data ) assert list_result.isError is False, ( f"MCP list events failed: {list_result.content}" ) events_data = json.loads(list_result.content[0].text) # Debug output to understand what nc_calendar_list_events returns logger.info(f"list_events result type: {type(events_data)}") logger.info(f"list_events result content: {events_data}") # Handle single event returned as dict instead of list (same fix as calendars) if isinstance(events_data, dict): # Single event returned as dict instead of list events_data = [events_data] assert isinstance(events_data, list), "Expected events list" # Our created event should be in the list found_event = None for event in events_data: if event.get("uid") == event_uid: found_event = event break assert found_event is not None, ( f"Created event {event_uid} not found in events list" ) assert found_event["title"] == test_event_title # 6. Test list events across all calendars logger.info("Testing nc_calendar_list_events across all calendars") all_calendars_data = { "calendar_name": "", # Will be ignored "search_all_calendars": True, "start_date": start_date, "end_date": end_date, "title_contains": unique_suffix, } all_list_result = await nc_mcp_client.call_tool( "nc_calendar_list_events", all_calendars_data ) assert all_list_result.isError is False, ( f"MCP list all events failed: {all_list_result.content}" ) all_events_data = json.loads(all_list_result.content[0].text) # Handle single event returned as dict instead of list (same fix as calendars) if isinstance(all_events_data, dict): # Single event returned as dict instead of list all_events_data = [all_events_data] assert isinstance(all_events_data, list), "Expected events list" # Our event should still be found when searching all calendars found_in_all = any(event.get("uid") == event_uid for event in all_events_data) assert found_in_all, "Event not found when searching all calendars" # 7. Update event via MCP updated_title = f"Updated {test_event_title}" updated_description = f"Updated description {unique_suffix}" update_data = { "calendar_name": calendar_name, "event_uid": event_uid, "title": updated_title, "description": updated_description, "priority": 1, } logger.info(f"Updating event via MCP: {event_uid}") update_result = await nc_mcp_client.call_tool( "nc_calendar_update_event", update_data ) assert update_result.isError is False, ( f"MCP event update failed: {update_result.content}" ) # 8. Verify update via direct NextcloudClient updated_direct_event, _ = await nc_client.calendar.get_event( calendar_name, event_uid ) assert updated_direct_event["title"] == updated_title assert updated_direct_event["description"] == updated_description assert updated_direct_event["priority"] == 1 # 9. Test upcoming events via MCP logger.info("Testing nc_calendar_get_upcoming_events via MCP") upcoming_result = await nc_mcp_client.call_tool( "nc_calendar_get_upcoming_events", {"calendar_name": calendar_name, "days_ahead": 7, "limit": 10}, ) assert upcoming_result.isError is False, ( f"MCP upcoming events failed: {upcoming_result.content}" ) upcoming_events = json.loads(upcoming_result.content[0].text) # Handle single event returned as dict instead of list (same fix as other tools) if isinstance(upcoming_events, dict): # Single event returned as dict instead of list upcoming_events = [upcoming_events] assert isinstance(upcoming_events, list), "Expected upcoming events list" # 10. Delete event via MCP logger.info(f"Deleting event via MCP: {event_uid}") delete_result = await nc_mcp_client.call_tool( "nc_calendar_delete_event", {"calendar_name": calendar_name, "event_uid": event_uid}, ) assert delete_result.isError is False, ( f"MCP event deletion failed: {delete_result.content}" ) # 11. Verify deletion via direct NextcloudClient try: await nc_client.calendar.get_event(calendar_name, event_uid) pytest.fail("Event should have been deleted but was still found") except Exception: # Expected - event should be deleted logger.info(f"Successfully verified event {event_uid} was deleted") created_event = None # Mark as cleaned up except Exception as e: if "Calendar app may not be enabled" in str( e ) or "No calendars available" in str(e): pytest.skip("Calendar functionality not available for testing") raise finally: # Cleanup in case of test failure if created_event is not None: try: await nc_client.calendar.delete_event( created_event["calendar_name"], created_event["uid"] ) logger.info( f"Cleaned up event {created_event['uid']} after test failure" ) except Exception as e: logger.warning(f"Failed to cleanup event: {e}")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/No-Smoke/nextcloud-mcp-comprehensive'

If you have feedback or need assistance with the MCP directory API, please join our Discord server