diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 380e31b..9e182a9 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -56,6 +56,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 + await ctx.warning( + "This resource is deprecated and will be removed in a future version" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client return await client.capabilities() diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 034a430..8d2ddad 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -5,6 +5,10 @@ from mcp.server.fastmcp import Context, FastMCP from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.models.deck import ( + DeckBoard, + DeckStack, + DeckCard, + DeckLabel, CreateBoardResponse, CreateStackResponse, StackOperationResponse, @@ -25,6 +29,7 @@ def configure_deck_tools(mcp: FastMCP): async def deck_boards_resource(): """List all Nextcloud Deck boards""" ctx: Context = mcp.get_context() + await ctx.warning("This message is deprecated, use the deck_get_board instead") client: NextcloudClient = ctx.request_context.lifespan_context.client boards = await client.deck.get_boards() return [board.model_dump() for board in boards] @@ -33,6 +38,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_board_resource(board_id: int): """Get details of a specific Nextcloud Deck board""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_board tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client board = await client.deck.get_board(board_id) return board.model_dump() @@ -41,6 +49,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_stacks_resource(board_id: int): """List all stacks in a Nextcloud Deck board""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_stacks tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client stacks = await client.deck.get_stacks(board_id) return [stack.model_dump() for stack in stacks] @@ -49,6 +60,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_stack_resource(board_id: int, stack_id: int): """Get details of a specific Nextcloud Deck stack""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_stack tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client stack = await client.deck.get_stack(board_id, stack_id) return stack.model_dump() @@ -57,6 +71,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_cards_resource(board_id: int, stack_id: int): """List all cards in a Nextcloud Deck stack""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_cards tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client stack = await client.deck.get_stack(board_id, stack_id) if stack.cards: @@ -67,6 +84,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_card_resource(board_id: int, stack_id: int, card_id: int): """Get details of a specific Nextcloud Deck card""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_card tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client card = await client.deck.get_card(board_id, stack_id, card_id) return card.model_dump() @@ -75,6 +95,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_labels_resource(board_id: int): """List all labels in a Nextcloud Deck board""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_labels tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client board = await client.deck.get_board(board_id) return [label.model_dump() for label in board.labels] @@ -83,11 +106,78 @@ def configure_deck_tools(mcp: FastMCP): async def deck_label_resource(board_id: int, label_id: int): """Get details of a specific Nextcloud Deck label""" ctx: Context = mcp.get_context() + await ctx.warning( + "This resource is deprecated, use the deck_get_label tool instead" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client label = await client.deck.get_label(board_id, label_id) return label.model_dump() - # Tools + # Read Tools (converted from resources) + + @mcp.tool() + async def deck_get_boards(ctx: Context) -> list[DeckBoard]: + """Get all Nextcloud Deck boards""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + boards = await client.deck.get_boards() + return boards + + @mcp.tool() + async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: + """Get details of a specific Nextcloud Deck board""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return board + + @mcp.tool() + async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: + """Get all stacks in a Nextcloud Deck board""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + stacks = await client.deck.get_stacks(board_id) + return stacks + + @mcp.tool() + async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: + """Get details of a specific Nextcloud Deck stack""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + stack = await client.deck.get_stack(board_id, stack_id) + return stack + + @mcp.tool() + async def deck_get_cards( + ctx: Context, board_id: int, stack_id: int + ) -> list[DeckCard]: + """Get all cards in a Nextcloud Deck stack""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + stack = await client.deck.get_stack(board_id, stack_id) + if stack.cards: + return stack.cards + return [] + + @mcp.tool() + async def deck_get_card( + ctx: Context, board_id: int, stack_id: int, card_id: int + ) -> DeckCard: + """Get details of a specific Nextcloud Deck card""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + card = await client.deck.get_card(board_id, stack_id, card_id) + return card + + @mcp.tool() + async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: + """Get all labels in a Nextcloud Deck board""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return board.labels + + @mcp.tool() + async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: + """Get details of a specific Nextcloud Deck label""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + label = await client.deck.get_label(board_id, label_id) + return label + + # Create/Update/Delete Tools @mcp.tool() async def deck_create_board( diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index cd21d45..607dee6 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -27,12 +27,15 @@ def configure_notes_tools(mcp: FastMCP): ctx: Context = ( mcp.get_context() ) # https://github.com/modelcontextprotocol/python-sdk/issues/244 + await ctx.warning( + "This resource is deprecated and will be removed in a future version" + ) client: NextcloudClient = ctx.request_context.lifespan_context.client settings_data = await client.notes.get_settings() return NotesSettings(**settings_data) @mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") - async def nc_notes_get_attachment(note_id: int, attachment_filename: str): + async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str): """Get a specific attachment from a note""" ctx: Context = mcp.get_context() client: NextcloudClient = ctx.request_context.lifespan_context.client @@ -53,7 +56,7 @@ def configure_notes_tools(mcp: FastMCP): } @mcp.resource("nc://Notes/{note_id}") - async def nc_get_note(note_id: int): + async def nc_get_note_resource(note_id: int): """Get user note using note id""" ctx: Context = mcp.get_context() @@ -129,7 +132,7 @@ def configure_notes_tools(mcp: FastMCP): """Update an existing note's title, content, or category. REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes. - Get the current ETag by first retrieving the note using nc://Notes/{note_id} resource. + Get the current ETag by first retrieving the note using nc_notes_get_note tool. If the note has been modified by someone else since you retrieved it, the update will fail with a 412 error.""" logger.info("Updating note %s", note_id) @@ -258,6 +261,66 @@ def configure_notes_tools(mcp: FastMCP): ) ) + @mcp.tool() + async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: + """Get a specific note by its ID""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + try: + note_data = await client.notes.get_note(note_id) + return Note(**note_data) + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) + elif e.response.status_code == 403: + raise McpError( + ErrorData(code=-1, message=f"Access denied to note {note_id}") + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to retrieve note {note_id}: {e.response.reason_phrase}", + ) + ) + + @mcp.tool() + async def nc_notes_get_attachment( + note_id: int, attachment_filename: str, ctx: Context + ) -> dict[str, str]: + """Get a specific attachment from a note""" + client: NextcloudClient = ctx.request_context.lifespan_context.client + try: + content, mime_type = await client.webdav.get_note_attachment( + note_id=note_id, filename=attachment_filename + ) + return { + "uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}", + "mimeType": mime_type, + "data": content, + } + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError( + ErrorData( + code=-1, + message=f"Attachment {attachment_filename} not found for note {note_id}", + ) + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message=f"Access denied to attachment {attachment_filename} for note {note_id}", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to retrieve attachment: {e.response.reason_phrase}", + ) + ) + @mcp.tool() async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: """Delete a note permanently""" diff --git a/tests/integration/test_error_propagation.py b/tests/integration/test_error_propagation.py index 623d5ab..8cf6667 100644 --- a/tests/integration/test_error_propagation.py +++ b/tests/integration/test_error_propagation.py @@ -2,7 +2,6 @@ import logging from mcp import ClientSession -from mcp.shared.exceptions import McpError import pytest @@ -10,11 +9,15 @@ logger = logging.getLogger(__name__) @pytest.mark.integration -async def test_missing_note_resource_error(nc_mcp_client: ClientSession): - """Test that accessing a non-existent note resource returns proper error.""" - # Try to get a non-existent note via resource - should raise McpError with improved message - with pytest.raises(McpError, match=r"Note 999999 not found"): - await nc_mcp_client.read_resource("nc://Notes/999999") +async def test_missing_note_tool_error(nc_mcp_client: ClientSession): + """Test that accessing a non-existent note via tool returns proper error.""" + # Try to get a non-existent note via tool - should return error response + response = await nc_mcp_client.call_tool("nc_notes_get_note", {"note_id": 999999}) + + # Should return error response (not raise exception) for tools + assert response is not None + assert response.isError is True + assert "Note 999999 not found" in response.content[0].text @pytest.mark.integration diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py index ea65c7d..c3074fe 100644 --- a/tests/integration/test_mcp.py +++ b/tests/integration/test_mcp.py @@ -68,7 +68,8 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): template_uris.append(template.uriTemplate) # Verify expected resource templates - expected_templates = ["nc://Notes/{note_id}/attachments/{attachment_filename}"] + # Note: Notes attachments are now handled via tools, not resource templates + expected_templates = [] for expected_template in expected_templates: assert expected_template in template_uris, ( @@ -140,9 +141,11 @@ async def test_mcp_notes_crud_workflow( # 3. Read note via MCP logger.info(f"Reading note via MCP: {note_id}") - read_result = await nc_mcp_client.read_resource(f"nc://Notes/{note_id}") - assert len(read_result.contents) == 1, "Expected exactly one content item" - read_note_data = json.loads(read_result.contents[0].text) + read_result = await nc_mcp_client.call_tool( + "nc_notes_get_note", {"note_id": note_id} + ) + read_note_data = read_result.content[0].text + read_note_data = json.loads(read_note_data) assert read_note_data["title"] == test_title assert read_note_data["content"] == test_content