Files
mcp-nextcloud/nextcloud_mcp_server/server/talk.py
T
Chris CoutinhoandClaude Opus 4.7 f075540232 fix(talk): address remaining PR #741 reviewer feedback
Closes the seven outstanding items from the @claude review on PR #741:

1. Add empty `tests/client/talk/__init__.py` for pytest discovery parity
   with `tests/client/{collectives,news}/`.
2. Standardise boolean query params to integers — `includeStatus` was the
   string `"true"` in `list_conversations`/`list_participants` while every
   other flag (`noStatusUpdate`, `lookIntoFuture`, `setReadMarker`,
   `includeLastKnown`) used `1`/`0`.
3. Replace the `app:install || app:enable` chain in the spreed install hook
   with `app:install --keep-disabled --force || true; app:enable spreed`,
   so unrelated install failures surface as a clear "app not found" from
   `app:enable` rather than being silently masked.
4. Add `_validate_token()` (alphanumeric whitelist) and call it from all
   six TalkClient methods that interpolate the token into a URL path —
   defence-in-depth against pathological tokens reaching httpx.
5. Rename `TalkConversation.type` to `room_type` with `Field(alias="type")`
   and `populate_by_name=True`, so the field no longer shadows Python's
   builtin while preserving spreed's wire format on input. MCP responses
   now serialize `room_type` (field name) instead of `type`.
6. `mark_as_read` now passes `json=body or None` so the bodyless
   "mark everything as read" call doesn't send a spurious `{}` body and
   `Content-Type: application/json` header.
7. `_validate_message_text` rejects whitespace-only messages, not just
   empty strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:14:32 +02:00

226 lines
7.7 KiB
Python

"""MCP tool registration for the Nextcloud Talk (spreed) integration."""
import logging
import uuid
from mcp.server.fastmcp import Context, FastMCP
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.talk import (
GetConversationResponse,
ListConversationsResponse,
ListMessagesResponse,
ListParticipantsResponse,
MarkAsReadResponse,
SendMessageResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
# spreed advertises a 32000-character limit on chat messages (docs/chat.md);
# we enforce it client-side for a clearer error than the server's 413.
_MESSAGE_MAX_LENGTH = 32000
def _validate_message_text(message: str) -> None:
# Reject both empty strings and whitespace-only strings — spreed
# would happily post the latter as a visually-blank message.
if not message or not message.strip():
raise ValueError("Message text must not be empty or whitespace-only")
if len(message) > _MESSAGE_MAX_LENGTH:
raise ValueError(
f"Message too long: {len(message)} characters (max {_MESSAGE_MAX_LENGTH})"
)
def configure_talk_tools(mcp: FastMCP) -> None:
"""Configure Nextcloud Talk (spreed) MCP tools."""
# Read tools
@mcp.tool(
title="List Talk Conversations",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("talk.read")
@instrument_tool
async def talk_list_conversations(
ctx: Context,
modified_since: int | None = None,
include_status: bool = False,
) -> ListConversationsResponse:
"""List the user's Talk conversations (rooms).
Args:
modified_since: Optional Unix timestamp; only conversations
modified after this time are returned.
include_status: Whether to include user-status info for
one-to-one conversations.
"""
client = await get_client(ctx)
rooms = await client.talk.list_conversations(
modified_since=modified_since,
include_status=include_status,
)
return ListConversationsResponse(results=rooms, total=len(rooms))
@mcp.tool(
title="Get Talk Conversation",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("talk.read")
@instrument_tool
async def talk_get_conversation(
ctx: Context, token: str
) -> GetConversationResponse:
"""Get details of a Talk conversation by its token.
Args:
token: Unique room token (returned by ``talk_list_conversations``).
"""
client = await get_client(ctx)
conversation = await client.talk.get_conversation(token)
return GetConversationResponse(conversation=conversation)
@mcp.tool(
title="Get Talk Messages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("talk.read")
@instrument_tool
async def talk_get_messages(
ctx: Context,
token: str,
limit: int = 50,
last_known_message_id: int | None = None,
include_last_known: bool = False,
) -> ListMessagesResponse:
"""Read chat history for a Talk conversation.
Returns the most recent messages (older first when paginated).
Does not move the user's read marker; call
``talk_mark_as_read`` separately if desired.
Args:
token: Conversation token.
limit: Max messages per page. Valid range is 1-200 (spreed
caps server-side at 200); values outside this range are
clamped. Default 50.
last_known_message_id: Pagination cursor — pass the
``last_known_message_id`` from the previous response to
fetch the next (older) page.
include_last_known: Include the cursor message in the page
instead of starting just before it.
"""
client = await get_client(ctx)
messages, last_given = await client.talk.get_messages(
token,
limit=limit,
last_known_message_id=last_known_message_id,
look_into_future=False,
set_read_marker=False,
include_last_known=include_last_known,
)
return ListMessagesResponse(
conversation_token=token,
results=messages,
count=len(messages),
last_known_message_id=last_given,
)
@mcp.tool(
title="List Talk Conversation Participants",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("talk.read")
@instrument_tool
async def talk_list_participants(
ctx: Context, token: str, include_status: bool = False
) -> ListParticipantsResponse:
"""List the participants of a Talk conversation.
Args:
token: Conversation token.
include_status: Include each participant's user-status info.
"""
client = await get_client(ctx)
participants = await client.talk.list_participants(
token, include_status=include_status
)
return ListParticipantsResponse(
conversation_token=token,
results=participants,
count=len(participants),
)
# Write tools
@mcp.tool(
title="Send Talk Message",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("talk.write")
@instrument_tool
async def talk_send_message(
ctx: Context,
token: str,
message: str,
reply_to: int | None = None,
silent: bool = False,
) -> SendMessageResponse:
"""Post a chat message into a Talk conversation as the user.
A random ``referenceId`` is attached so spreed dedupes the post
if the request is retried.
Args:
token: Conversation token.
message: Message text (max 32000 characters).
reply_to: Optional parent message ID to thread the reply.
silent: When True the message is delivered without push
notifications (e.g. for status updates).
"""
_validate_message_text(message)
client = await get_client(ctx)
posted = await client.talk.send_message(
token,
message,
reply_to=reply_to,
# 32 hex chars, no dashes — spreed accepts either UUID format.
reference_id=uuid.uuid4().hex,
silent=silent,
)
return SendMessageResponse(message=posted)
@mcp.tool(
title="Mark Talk Conversation as Read",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("talk.write")
@instrument_tool
async def talk_mark_as_read(
ctx: Context,
token: str,
last_read_message: int | None = None,
) -> MarkAsReadResponse:
"""Move the user's read marker forward in a Talk conversation.
Args:
token: Conversation token.
last_read_message: Optional message ID to mark as the new
read position. When omitted, spreed marks everything
currently in the room as read.
"""
client = await get_client(ctx)
await client.talk.mark_as_read(token, last_read_message=last_read_message)
return MarkAsReadResponse(
success=True,
message="Conversation marked as read",
conversation_token=token,
last_read_message=last_read_message,
)