From 454f6912bc2b36beebea6ae92b516c49573a9405 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 29 Apr 2026 13:39:24 +0200 Subject: [PATCH] feat(deck): add card comment tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose four new MCP tools backed by existing DeckClient comment methods: - deck_get_card_comments — list with limit/offset pagination - deck_create_card_comment — top-level or threaded (via parent_id) - deck_update_card_comment — author-only on the server - deck_delete_card_comment — author-only, destructive, idempotent Adds ListCardCommentsResponse and CardCommentOperationResponse models, and extends the client unit tests to cover replies, deletion, pagination, and the request shape for updates. Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/models/deck.py | 17 +++++ nextcloud_mcp_server/server/deck.py | 99 +++++++++++++++++++++++++++++ tests/client/deck/test_deck_api.py | 90 ++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py index 8c42bff8..6fa7357e 100644 --- a/nextcloud_mcp_server/models/deck.py +++ b/nextcloud_mcp_server/models/deck.py @@ -280,3 +280,20 @@ class LabelOperationResponse(StatusResponse): label_id: int = Field(description="ID of the affected label") board_id: int = Field(description="ID of the board containing the label") + + +# Comment Response Models + + +class ListCardCommentsResponse(BaseResponse): + """Response model for listing card comments.""" + + results: list[DeckComment] = Field(description="List of card comments") + total: int = Field(description="Number of comments returned") + + +class CardCommentOperationResponse(StatusResponse): + """Response model for card comment create/update/delete operations.""" + + card_id: int = Field(description="ID of the card the comment belongs to") + comment_id: int = Field(description="ID of the affected comment") diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index f9ae9dc3..aa3ef1c9 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -7,6 +7,7 @@ from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( + CardCommentOperationResponse, CardOperationResponse, CreateBoardResponse, CreateCardResponse, @@ -14,10 +15,12 @@ from nextcloud_mcp_server.models.deck import ( CreateStackResponse, DeckBoard, DeckCard, + DeckComment, DeckLabel, DeckStack, LabelOperationResponse, ListBoardsResponse, + ListCardCommentsResponse, ListCardsResponse, ListLabelsResponse, ListStacksResponse, @@ -722,3 +725,99 @@ def configure_deck_tools(mcp: FastMCP): stack_id=stack_id, board_id=board_id, ) + + # Card Comment Tools + + @mcp.tool( + title="List Deck Card Comments", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) + @require_scopes("deck.read") + @instrument_tool + async def deck_get_card_comments( + ctx: Context, card_id: int, limit: int = 20, offset: int = 0 + ) -> ListCardCommentsResponse: + """List comments on a Nextcloud Deck card + + Args: + card_id: The ID of the card + limit: Maximum number of comments to return (default 20, max 200) + offset: Pagination offset (default 0) + """ + client = await get_client(ctx) + comments = await client.deck.get_comments(card_id, limit=limit, offset=offset) + return ListCardCommentsResponse(results=comments, total=len(comments)) + + @mcp.tool( + title="Create Deck Card Comment", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) + @require_scopes("deck.write") + @instrument_tool + async def deck_create_card_comment( + ctx: Context, + card_id: int, + message: str, + parent_id: Optional[int] = None, + ) -> DeckComment: + """Create a comment on a Nextcloud Deck card + + Supports @-mentions (e.g. "@alice"). Pass parent_id to reply to an + existing comment on the same card. Message is limited to 1000 characters. + + Args: + card_id: The ID of the card to comment on + message: The comment text (max 1000 characters) + parent_id: Optional ID of a parent comment to reply to + """ + client = await get_client(ctx) + return await client.deck.create_comment(card_id, message, parent_id=parent_id) + + @mcp.tool( + title="Update Deck Card Comment", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) + @require_scopes("deck.write") + @instrument_tool + async def deck_update_card_comment( + ctx: Context, card_id: int, comment_id: int, message: str + ) -> DeckComment: + """Update a Nextcloud Deck card comment + + Only the comment's author can update it; the server returns 403 otherwise. + + Args: + card_id: The ID of the card the comment belongs to + comment_id: The ID of the comment to update + message: The new comment text (max 1000 characters) + """ + client = await get_client(ctx) + return await client.deck.update_comment(card_id, comment_id, message) + + @mcp.tool( + title="Delete Deck Card Comment", + annotations=ToolAnnotations( + destructiveHint=True, idempotentHint=True, openWorldHint=True + ), + ) + @require_scopes("deck.write") + @instrument_tool + async def deck_delete_card_comment( + ctx: Context, card_id: int, comment_id: int + ) -> CardCommentOperationResponse: + """Delete a Nextcloud Deck card comment + + Only the comment's author can delete it; the server returns 403 otherwise. + + Args: + card_id: The ID of the card the comment belongs to + comment_id: The ID of the comment to delete + """ + client = await get_client(ctx) + await client.deck.delete_comment(card_id, comment_id) + return CardCommentOperationResponse( + success=True, + message="Comment deleted successfully", + card_id=card_id, + comment_id=comment_id, + ) diff --git a/tests/client/deck/test_deck_api.py b/tests/client/deck/test_deck_api.py index db333e21..c5bec571 100644 --- a/tests/client/deck/test_deck_api.py +++ b/tests/client/deck/test_deck_api.py @@ -475,6 +475,96 @@ async def test_deck_update_comment(mocker): assert comment.message == "Updated comment" mock_make_request.assert_called_once() + call_args = mock_make_request.call_args + assert call_args[0][0] == "PUT" + assert "/cards/789/comments/222" in call_args[0][1] + assert call_args[1]["json"] == {"message": "Updated comment"} + + +async def test_deck_create_comment_reply(mocker): + """Test that create_comment forwards parent_id when replying.""" + mock_response = create_mock_deck_comment_response( + comment_id=333, message="A reply", card_id=789 + ) + + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + DeckClient, "_make_request", return_value=mock_response + ) + + client = DeckClient(mock_client, "testuser") + comment = await client.create_comment(card_id=789, message="A reply", parent_id=222) + + assert isinstance(comment, DeckComment) + assert comment.id == 333 + + mock_make_request.assert_called_once() + call_args = mock_make_request.call_args + assert call_args[0][0] == "POST" + assert "/cards/789/comments" in call_args[0][1] + assert call_args[1]["json"] == {"message": "A reply", "parentId": 222} + + +async def test_deck_create_comment_omits_parent_id_when_none(mocker): + """Test that create_comment does not send parentId when not given.""" + mock_response = create_mock_deck_comment_response( + comment_id=444, message="Top-level", card_id=789 + ) + + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + DeckClient, "_make_request", return_value=mock_response + ) + + client = DeckClient(mock_client, "testuser") + await client.create_comment(card_id=789, message="Top-level") + + call_args = mock_make_request.call_args + assert call_args[1]["json"] == {"message": "Top-level"} + assert "parentId" not in call_args[1]["json"] + + +async def test_deck_delete_comment(mocker): + """Test that delete_comment makes the correct API call.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + DeckClient, "_make_request", return_value=mock_response + ) + + client = DeckClient(mock_client, "testuser") + result = await client.delete_comment(card_id=789, comment_id=222) + + assert result is None + mock_make_request.assert_called_once() + call_args = mock_make_request.call_args + assert call_args[0][0] == "DELETE" + assert "/cards/789/comments/222" in call_args[0][1] + + +async def test_deck_get_comments_pagination(mocker): + """Test that get_comments forwards limit and offset as query params.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + DeckClient, "_make_request", return_value=mock_response + ) + + client = DeckClient(mock_client, "testuser") + await client.get_comments(card_id=789, limit=50, offset=100) + + call_args = mock_make_request.call_args + assert call_args[0][0] == "GET" + assert "/cards/789/comments" in call_args[0][1] + assert call_args[1]["params"] == {"limit": 50, "offset": 100} # Config Test