Merge pull request #737 from cbcoutinho/feat/deck-card-comments
feat(deck): add card comment tools
This commit is contained in:
@@ -280,3 +280,31 @@ 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="Card comments in this page")
|
||||
count: int = Field(
|
||||
description=(
|
||||
"Number of comments returned in this page (page size, not the "
|
||||
"server-side total — the Deck list endpoint does not expose a total)."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CardCommentResponse(BaseResponse):
|
||||
"""Response model returned when a single card comment is created or updated."""
|
||||
|
||||
comment: DeckComment = Field(description="The created or updated card comment")
|
||||
|
||||
|
||||
class CardCommentOperationResponse(StatusResponse):
|
||||
"""Response model for card comment operations that don't return comment data (e.g. delete)."""
|
||||
|
||||
card_id: int = Field(description="ID of the card the comment belongs to")
|
||||
comment_id: int = Field(description="ID of the affected comment")
|
||||
|
||||
@@ -7,6 +7,8 @@ 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,
|
||||
CardCommentResponse,
|
||||
CardOperationResponse,
|
||||
CreateBoardResponse,
|
||||
CreateCardResponse,
|
||||
@@ -18,6 +20,7 @@ from nextcloud_mcp_server.models.deck import (
|
||||
DeckStack,
|
||||
LabelOperationResponse,
|
||||
ListBoardsResponse,
|
||||
ListCardCommentsResponse,
|
||||
ListCardsResponse,
|
||||
ListLabelsResponse,
|
||||
ListStacksResponse,
|
||||
@@ -722,3 +725,114 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id=stack_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
# Card Comment Tools
|
||||
|
||||
_COMMENT_MAX_LENGTH = 1000
|
||||
|
||||
def _validate_comment_message(message: str) -> None:
|
||||
if len(message) > _COMMENT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Comment message too long: {len(message)} characters "
|
||||
f"(max {_COMMENT_MAX_LENGTH})"
|
||||
)
|
||||
|
||||
@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, count=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: int | None = None,
|
||||
) -> CardCommentResponse:
|
||||
"""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
|
||||
"""
|
||||
_validate_comment_message(message)
|
||||
client = await get_client(ctx)
|
||||
comment = await client.deck.create_comment(
|
||||
card_id, message, parent_id=parent_id
|
||||
)
|
||||
return CardCommentResponse(comment=comment)
|
||||
|
||||
@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
|
||||
) -> CardCommentResponse:
|
||||
"""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)
|
||||
"""
|
||||
_validate_comment_message(message)
|
||||
client = await get_client(ctx)
|
||||
comment = await client.deck.update_comment(card_id, comment_id, message)
|
||||
return CardCommentResponse(comment=comment)
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -221,3 +221,135 @@ async def test_deck_workflow_integration_mcp(
|
||||
# 3. Verify board data matches via resource (already done in step 1)
|
||||
logger.info(f"Board data verification completed for board: {board_id}")
|
||||
logger.info("Board structure and data verified successfully")
|
||||
|
||||
|
||||
# Card Comment Tests
|
||||
|
||||
|
||||
async def test_deck_card_comment_crud_workflow_mcp(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
temporary_board_with_card: tuple,
|
||||
):
|
||||
"""Full CRUD lifecycle for card comments via MCP tools."""
|
||||
_, _, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
|
||||
# 1. Create a top-level comment via MCP
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card_comment",
|
||||
{"card_id": card_id, "message": "Initial comment"},
|
||||
)
|
||||
assert create_result.isError is False, (
|
||||
f"Comment creation failed: {create_result.content}"
|
||||
)
|
||||
create_response = json.loads(create_result.content[0].text)
|
||||
assert create_response["success"] is True
|
||||
comment = create_response["comment"]
|
||||
comment_id = comment["id"]
|
||||
assert comment["objectId"] == card_id
|
||||
assert comment["message"] == "Initial comment"
|
||||
assert comment["replyTo"] is None
|
||||
logger.info(f"Created comment ID {comment_id} on card {card_id}")
|
||||
|
||||
# 2. List comments via MCP — verify the new comment is present
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"deck_get_card_comments", {"card_id": card_id}
|
||||
)
|
||||
assert list_result.isError is False, f"List comments failed: {list_result.content}"
|
||||
listed = json.loads(list_result.content[0].text)
|
||||
assert listed["count"] >= 1
|
||||
listed_ids = [c["id"] for c in listed["results"]]
|
||||
assert comment_id in listed_ids, "Created comment not in list"
|
||||
|
||||
# 3. Cross-check via direct client
|
||||
direct_comments = await nc_client.deck.get_comments(card_id)
|
||||
direct_ids = [c.id for c in direct_comments]
|
||||
assert comment_id in direct_ids, "Created comment not visible via direct client"
|
||||
|
||||
# 4. Update the comment via MCP
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_card_comment",
|
||||
{
|
||||
"card_id": card_id,
|
||||
"comment_id": comment_id,
|
||||
"message": "Edited comment",
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False, (
|
||||
f"Comment update failed: {update_result.content}"
|
||||
)
|
||||
update_response = json.loads(update_result.content[0].text)
|
||||
updated = update_response["comment"]
|
||||
assert updated["id"] == comment_id
|
||||
assert updated["message"] == "Edited comment"
|
||||
|
||||
# 5. Delete the comment via MCP
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"deck_delete_card_comment",
|
||||
{"card_id": card_id, "comment_id": comment_id},
|
||||
)
|
||||
assert delete_result.isError is False, (
|
||||
f"Comment delete failed: {delete_result.content}"
|
||||
)
|
||||
delete_response = json.loads(delete_result.content[0].text)
|
||||
assert delete_response["success"] is True
|
||||
assert delete_response["card_id"] == card_id
|
||||
assert delete_response["comment_id"] == comment_id
|
||||
|
||||
# 6. Verify the comment is gone
|
||||
final_list_result = await nc_mcp_client.call_tool(
|
||||
"deck_get_card_comments", {"card_id": card_id}
|
||||
)
|
||||
final_listed = json.loads(final_list_result.content[0].text)
|
||||
final_ids = [c["id"] for c in final_listed["results"]]
|
||||
assert comment_id not in final_ids, "Comment still present after delete"
|
||||
|
||||
|
||||
async def test_deck_card_comment_reply_mcp(
|
||||
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||
):
|
||||
"""Replying with parent_id populates replyTo on the new comment."""
|
||||
_, _, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
|
||||
# Create the parent comment
|
||||
parent_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card_comment",
|
||||
{"card_id": card_id, "message": "Parent message"},
|
||||
)
|
||||
assert parent_result.isError is False
|
||||
parent = json.loads(parent_result.content[0].text)["comment"]
|
||||
parent_id = parent["id"]
|
||||
|
||||
# Create a reply
|
||||
reply_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card_comment",
|
||||
{
|
||||
"card_id": card_id,
|
||||
"message": "Reply message",
|
||||
"parent_id": parent_id,
|
||||
},
|
||||
)
|
||||
assert reply_result.isError is False, f"Reply failed: {reply_result.content}"
|
||||
reply = json.loads(reply_result.content[0].text)["comment"]
|
||||
|
||||
assert reply["message"] == "Reply message"
|
||||
assert reply["replyTo"] is not None, "replyTo should be populated for replies"
|
||||
assert reply["replyTo"]["id"] == parent_id
|
||||
assert reply["replyTo"]["message"] == "Parent message"
|
||||
|
||||
|
||||
async def test_deck_card_comment_message_too_long_mcp(
|
||||
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||
):
|
||||
"""Creating a comment longer than 1000 chars is rejected client-side."""
|
||||
_, _, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
|
||||
too_long = "x" * 1001
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card_comment",
|
||||
{"card_id": card_id, "message": too_long},
|
||||
)
|
||||
assert result.isError is True, "Expected validation error for >1000 char message"
|
||||
|
||||
Reference in New Issue
Block a user