feat(deck): add card comment tools

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) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-29 13:39:24 +02:00
co-authored by Claude Opus 4.7
parent a5f56eaf18
commit 454f6912bc
3 changed files with 206 additions and 0 deletions
+17
View File
@@ -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")
+99
View File
@@ -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,
)
+90
View File
@@ -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