From 69814f30e37c34aa420458a15a54679c139a7204 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 29 Apr 2026 23:19:57 +0200 Subject: [PATCH] feat(talk): add MCP integration for Nextcloud Talk (spreed) Adds 6 MCP tools so an LLM can read a user's Talk conversations and post messages on their behalf, addressing the "read my chats and reply" use case from issue #720: - talk_list_conversations - talk_get_conversation - talk_get_messages - talk_list_participants - talk_send_message (auto-attaches a referenceId for retry dedup) - talk_mark_as_read Edit/delete messages, reactions, threads, and call/session ops are intentionally out of scope for this first PR. The TalkClient also exposes create_conversation/delete_conversation for the integration test fixture; these are not registered as MCP tools. A post-installation hook enables spreed in the docker dev env so the integration suite has a real Talk backend to talk to. Closes #720 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + .../10-install-spreed-app.sh | 9 + .../auth/scope_authorization.py | 2 + nextcloud_mcp_server/client/__init__.py | 2 + nextcloud_mcp_server/client/talk.py | 238 +++++++++++ nextcloud_mcp_server/models/talk.py | 152 +++++++ nextcloud_mcp_server/server/__init__.py | 3 + nextcloud_mcp_server/server/talk.py | 220 ++++++++++ tests/client/conftest.py | 79 ++++ tests/client/talk/test_talk_api.py | 386 ++++++++++++++++++ tests/conftest.py | 46 ++- tests/server/test_talk_mcp.py | 143 +++++++ 12 files changed, 1278 insertions(+), 3 deletions(-) create mode 100755 app-hooks/post-installation/10-install-spreed-app.sh create mode 100644 nextcloud_mcp_server/client/talk.py create mode 100644 nextcloud_mcp_server/models/talk.py create mode 100644 nextcloud_mcp_server/server/talk.py create mode 100644 tests/client/talk/test_talk_api.py create mode 100644 tests/server/test_talk_mcp.py diff --git a/README.md b/README.md index 396cb33b..7b58f598 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ docker compose --profile login-flow up -d # Port 8004 | **Sharing** | 10+ | Create and manage shares | | **News** | 8 | Feeds, folders, items, feed health monitoring | | **Collectives** | 16 | Full CRUD on collectives, pages, and tags | +| **Talk (spreed)** | 6 | List conversations, read/post messages, mark as read, list participants | | **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) | Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request! diff --git a/app-hooks/post-installation/10-install-spreed-app.sh b/app-hooks/post-installation/10-install-spreed-app.sh new file mode 100755 index 00000000..a4b84974 --- /dev/null +++ b/app-hooks/post-installation/10-install-spreed-app.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -euox pipefail + +# Talk (spreed) ships its appstore release on demand; on a fresh container the +# app is not yet installed, so app:install pulls it. On rebuilds where the +# volume already has it, app:install would fail, so fall through to +# app:enable. +php /var/www/html/occ app:install spreed || php /var/www/html/occ app:enable spreed diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index 1a5e7a15..5317d271 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -209,6 +209,7 @@ def require_scopes(*required_scopes: str): "files.", "tables.", "deck.", + "talk.", ] ) ] @@ -229,6 +230,7 @@ def require_scopes(*required_scopes: str): "files.", "tables.", "deck.", + "talk.", ] ) diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 4b567efb..e28e419d 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -23,6 +23,7 @@ from .news import NewsClient from .notes import NotesClient from .sharing import SharingClient from .tables import TablesClient +from .talk import TalkClient from .users import UsersClient from .webdav import WebDAVClient from .webhooks import WebhooksClient @@ -85,6 +86,7 @@ class NextcloudClient: self.collectives = CollectivesClient(self._client, username) self.deck = DeckClient(self._client, username) self.news = NewsClient(self._client, username) + self.talk = TalkClient(self._client, username) self.users = UsersClient(self._client, username) self.groups = GroupsClient(self._client, username) self.sharing = SharingClient(self._client, username) diff --git a/nextcloud_mcp_server/client/talk.py b/nextcloud_mcp_server/client/talk.py new file mode 100644 index 00000000..8ee01e58 --- /dev/null +++ b/nextcloud_mcp_server/client/talk.py @@ -0,0 +1,238 @@ +"""HTTP client for the Nextcloud Talk (spreed) app. + +Talk exposes its REST API under ``/ocs/v2.php/apps/spreed/api/{v}/...``. +The current versions used here are: + +- conversations & participants: ``api/v4`` (Nextcloud 22+) +- chat: ``api/v1`` (Nextcloud 13+) + +All endpoints follow the OCS envelope ``{"ocs": {"meta": ..., "data": ...}}``, +require ``OCS-APIRequest: true`` and respond as JSON when ``Accept: +application/json`` is sent. +""" + +from typing import Any + +from nextcloud_mcp_server.client.base import BaseNextcloudClient +from nextcloud_mcp_server.models.talk import ( + TalkConversation, + TalkMessage, + TalkParticipant, +) + + +class TalkClient(BaseNextcloudClient): + """Client for Nextcloud Talk (spreed) app operations.""" + + app_name = "talk" + + _ROOM_BASE = "/ocs/v2.php/apps/spreed/api/v4/room" + _CHAT_BASE = "/ocs/v2.php/apps/spreed/api/v1/chat" + + def _talk_headers(self) -> dict[str, str]: + """Standard OCS+JSON headers for spreed API calls.""" + return { + "OCS-APIRequest": "true", + "Accept": "application/json", + "Content-Type": "application/json", + } + + # Conversations (rooms) + + async def list_conversations( + self, + *, + modified_since: int | None = None, + include_status: bool = False, + no_status_update: bool = True, + ) -> list[TalkConversation]: + """Return the user's Talk conversations. + + Args: + modified_since: If provided, only return conversations modified + after this Unix timestamp (server-side filter). + include_status: Include user-status info for one-to-one rooms. + no_status_update: When True (default), the call does not bump + the user's "online" status — appropriate for an MCP server + acting in the background. + """ + params: dict[str, Any] = {} + if modified_since is not None: + params["modifiedSince"] = modified_since + if include_status: + params["includeStatus"] = "true" + if no_status_update: + params["noStatusUpdate"] = 1 + response = await self._make_request( + "GET", self._ROOM_BASE, params=params, headers=self._talk_headers() + ) + data = response.json()["ocs"]["data"] + return [TalkConversation(**room) for room in data] + + async def get_conversation(self, token: str) -> TalkConversation: + """Fetch a single Talk conversation by its room token.""" + response = await self._make_request( + "GET", f"{self._ROOM_BASE}/{token}", headers=self._talk_headers() + ) + return TalkConversation(**response.json()["ocs"]["data"]) + + async def create_conversation( + self, + *, + room_type: int = 2, + room_name: str, + invite: str | None = None, + ) -> TalkConversation: + """Create a new conversation (used for tests/fixtures). + + Args: + room_type: 1=one-to-one, 2=group, 3=public. Defaults to 2. + room_name: Display name (required for group/public rooms). + invite: Optional user/group ID to invite at creation time. + + This client method is not exposed as an MCP tool in the initial + Talk integration; it exists so integration tests can spin up + scratch rooms. + """ + body: dict[str, Any] = {"roomType": room_type, "roomName": room_name} + if invite is not None: + body["invite"] = invite + response = await self._make_request( + "POST", self._ROOM_BASE, json=body, headers=self._talk_headers() + ) + return TalkConversation(**response.json()["ocs"]["data"]) + + async def delete_conversation(self, token: str) -> None: + """Delete a conversation. Used by integration test cleanup.""" + await self._make_request( + "DELETE", f"{self._ROOM_BASE}/{token}", headers=self._talk_headers() + ) + + # Chat + + async def get_messages( + self, + token: str, + *, + limit: int = 50, + last_known_message_id: int | None = None, + look_into_future: bool = False, + set_read_marker: bool = False, + include_last_known: bool = False, + ) -> tuple[list[TalkMessage], int | None]: + """Fetch chat messages for a conversation. + + Args: + token: Conversation token. + limit: Max messages to return (spreed caps this at 200). + last_known_message_id: Pagination cursor — pass the value + from the previous response's ``X-Chat-Last-Given`` header. + look_into_future: When False (default), return *older* + messages relative to ``last_known_message_id`` — i.e., + read history. When True, this becomes a long-poll for + new messages, which we don't expose via MCP. + set_read_marker: When False (default), the call does not move + the user's read marker — consumers can call + ``mark_as_read`` explicitly. + include_last_known: Include the message identified by + ``last_known_message_id`` itself in the page. + + Returns: + ``(messages, x_chat_last_given)`` where the integer is the + value of the ``X-Chat-Last-Given`` response header (or None + if the header was absent), suitable for pagination. + """ + params: dict[str, Any] = { + "limit": limit, + "lookIntoFuture": 1 if look_into_future else 0, + "setReadMarker": 1 if set_read_marker else 0, + "includeLastKnown": 1 if include_last_known else 0, + } + if last_known_message_id is not None: + params["lastKnownMessageId"] = last_known_message_id + response = await self._make_request( + "GET", + f"{self._CHAT_BASE}/{token}", + params=params, + headers=self._talk_headers(), + ) + # 200 OK → JSON body with messages; 304 Not Modified → no body. + # _make_request's raise_for_status() lets 3xx through for GET, but + # spreed returns 200 with an empty data list when there's nothing + # new, so we trust the JSON body here. + last_given_header = response.headers.get("X-Chat-Last-Given") + last_given = int(last_given_header) if last_given_header else None + data = response.json()["ocs"]["data"] + return [TalkMessage(**msg) for msg in data], last_given + + async def send_message( + self, + token: str, + message: str, + *, + reply_to: int | None = None, + reference_id: str | None = None, + silent: bool = False, + ) -> TalkMessage: + """Post a chat message to a conversation. + + Args: + token: Conversation token. + message: Message text (max 32000 chars per spreed docs/chat.md). + reply_to: Optional parent message ID to thread this reply. + reference_id: Optional client-provided UUID for idempotency on + retry (spreed dedupes on this within the conversation). + silent: When True, the message is delivered without push + notifications. + """ + body: dict[str, Any] = {"message": message} + if reply_to is not None: + body["replyTo"] = reply_to + if reference_id is not None: + body["referenceId"] = reference_id + if silent: + body["silent"] = True + response = await self._make_request( + "POST", + f"{self._CHAT_BASE}/{token}", + json=body, + headers=self._talk_headers(), + ) + return TalkMessage(**response.json()["ocs"]["data"]) + + async def mark_as_read( + self, token: str, *, last_read_message: int | None = None + ) -> None: + """Mark the conversation as read. + + If ``last_read_message`` is provided it sets the read marker to + that message; otherwise spreed marks everything currently in the + room as read. + """ + body: dict[str, Any] = {} + if last_read_message is not None: + body["lastReadMessage"] = last_read_message + await self._make_request( + "POST", + f"{self._CHAT_BASE}/{token}/read", + json=body, + headers=self._talk_headers(), + ) + + # Participants + + async def list_participants( + self, token: str, *, include_status: bool = False + ) -> list[TalkParticipant]: + """List participants of a Talk conversation.""" + params: dict[str, Any] = {} + if include_status: + params["includeStatus"] = "true" + response = await self._make_request( + "GET", + f"{self._ROOM_BASE}/{token}/participants", + params=params, + headers=self._talk_headers(), + ) + data = response.json()["ocs"]["data"] + return [TalkParticipant(**p) for p in data] diff --git a/nextcloud_mcp_server/models/talk.py b/nextcloud_mcp_server/models/talk.py new file mode 100644 index 00000000..35b66c72 --- /dev/null +++ b/nextcloud_mcp_server/models/talk.py @@ -0,0 +1,152 @@ +"""Pydantic models for the Nextcloud Talk (spreed) integration.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from .base import BaseResponse, StatusResponse + +# Domain models + + +class TalkMessage(BaseModel): + """A single chat message in a Talk conversation. + + See spreed docs/chat.md for the field definitions. We map only the + fields that are useful to MCP consumers; spreed returns more. + """ + + id: int + token: str + actorType: str + actorId: str + actorDisplayName: str + timestamp: int + systemMessage: str = "" + messageType: str + message: str + messageParameters: dict[str, Any] = Field(default_factory=dict) + expirationTimestamp: int | None = None + referenceId: str | None = None + markdown: bool | None = None + + @field_validator("messageParameters", mode="before") + @classmethod + def _coerce_empty_list_params(cls, v: Any) -> Any: + # spreed serializes an empty parameter map as `[]` (PHP array) rather + # than `{}`; normalize so pydantic accepts it as a dict. + if isinstance(v, list) and not v: + return {} + return v + + +class TalkConversation(BaseModel): + """A Talk conversation (room). + + See spreed docs/conversation.md for the full field reference. Many + optional fields are omitted; we keep the ones useful for chat-centric + flows. + """ + + id: int + token: str + type: int + name: str + displayName: str + description: str | None = "" + participantType: int | None = None + unreadMessages: int = 0 + unreadMention: bool = False + lastActivity: int | None = None + lastReadMessage: int | None = None + lastMessage: TalkMessage | None = None + readOnly: int | None = None + isFavorite: bool | None = None + notificationLevel: int | None = None + objectType: str | None = None + objectId: str | None = None + + @field_validator("lastMessage", mode="before") + @classmethod + def _coerce_empty_last_message(cls, v: Any) -> Any: + # spreed returns `lastMessage: []` (PHP empty array) when there has + # never been a message in the room; normalize to None. + if isinstance(v, list) and not v: + return None + return v + + +class TalkParticipant(BaseModel): + """A participant (attendee) in a Talk conversation.""" + + attendeeId: int + actorType: str + actorId: str + displayName: str + participantType: int + inCall: int = 0 + lastPing: int = 0 + sessionIds: list[str] = Field(default_factory=list) + status: str | None = None + statusIcon: str | None = None + statusMessage: str | None = None + + +# Response wrappers for MCP tools + + +class ListConversationsResponse(BaseResponse): + """Response model for listing Talk conversations.""" + + results: list[TalkConversation] = Field( + description="Talk conversations the user participates in" + ) + total: int = Field(description="Number of conversations returned") + + +class GetConversationResponse(BaseResponse): + """Response model for fetching a single Talk conversation.""" + + conversation: TalkConversation = Field(description="The Talk conversation") + + +class ListMessagesResponse(BaseResponse): + """Response model for fetching chat history of a conversation.""" + + conversation_token: str = Field(description="Token of the conversation") + results: list[TalkMessage] = Field(description="Chat messages in this page") + count: int = Field(description="Number of messages returned in this page") + last_known_message_id: int | None = Field( + default=None, + description=( + "ID to pass back as `last_known_message_id` to fetch the next " + "page (older history). Sourced from the `X-Chat-Last-Given` " + "response header." + ), + ) + + +class ListParticipantsResponse(BaseResponse): + """Response model for listing participants in a Talk conversation.""" + + conversation_token: str = Field(description="Token of the conversation") + results: list[TalkParticipant] = Field( + description="Participants of the conversation" + ) + count: int = Field(description="Number of participants returned") + + +class SendMessageResponse(BaseResponse): + """Response model returned after posting a chat message.""" + + message: TalkMessage = Field(description="The posted chat message") + + +class MarkAsReadResponse(StatusResponse): + """Response model for the mark-as-read operation.""" + + conversation_token: str = Field(description="Token of the conversation") + last_read_message: int | None = Field( + default=None, + description="The message ID that was marked as the last-read marker", + ) diff --git a/nextcloud_mcp_server/server/__init__.py b/nextcloud_mcp_server/server/__init__.py index 45fab885..e2f4616b 100644 --- a/nextcloud_mcp_server/server/__init__.py +++ b/nextcloud_mcp_server/server/__init__.py @@ -12,6 +12,7 @@ from .notes import configure_notes_tools from .semantic import configure_semantic_tools from .sharing import configure_sharing_tools from .tables import configure_tables_tools +from .talk import configure_talk_tools from .webdav import configure_webdav_tools # Canonical mapping of app name → tool registration function. @@ -29,6 +30,7 @@ AVAILABLE_APPS: dict[str, Callable[[FastMCP], None]] = { "cookbook": configure_cookbook_tools, "deck": configure_deck_tools, "news": configure_news_tools, + "talk": configure_talk_tools, } __all__ = [ @@ -43,5 +45,6 @@ __all__ = [ "configure_semantic_tools", "configure_sharing_tools", "configure_tables_tools", + "configure_talk_tools", "configure_webdav_tools", ] diff --git a/nextcloud_mcp_server/server/talk.py b/nextcloud_mcp_server/server/talk.py new file mode 100644 index 00000000..d1d91fd2 --- /dev/null +++ b/nextcloud_mcp_server/server/talk.py @@ -0,0 +1,220 @@ +"""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: + if not message: + raise ValueError("Message text must not be empty") + 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 (spreed caps at 200). + 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, + 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, + ) diff --git a/tests/client/conftest.py b/tests/client/conftest.py index 4fc8cc7e..c4649b4f 100644 --- a/tests/client/conftest.py +++ b/tests/client/conftest.py @@ -358,6 +358,85 @@ def create_mock_deck_comment_response( return create_mock_response(status_code=200, json_data=ocs_response) +def create_mock_talk_room_response( + conversation_id: int = 1, + token: str = "abc123", + name: str = "Test Conversation", + room_type: int = 2, + **kwargs, +) -> httpx.Response: + """Create a mock OCS response for a Nextcloud Talk conversation. + + Args: + conversation_id: Numeric conversation ID + token: Unique room token + name: Conversation name + room_type: 1=one-to-one, 2=group, 3=public, 4=changelog + **kwargs: Additional conversation fields (override defaults) + + Returns: + Mock httpx.Response with conversation data wrapped in OCS format. + """ + room_data = { + "id": conversation_id, + "token": token, + "type": room_type, + "name": name, + "displayName": name, + "description": "", + "participantType": 1, + "unreadMessages": 0, + "unreadMention": False, + "lastActivity": 1700000000, + "lastReadMessage": 0, + "lastMessage": [], + "readOnly": 0, + "isFavorite": False, + "notificationLevel": 0, + **kwargs, + } + + ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": room_data}} + return create_mock_response(status_code=200, json_data=ocs_response) + + +def create_mock_talk_message_response( + message_id: int = 1, + token: str = "abc123", + text: str = "Hello, world!", + actor_id: str = "testuser", + **kwargs, +) -> httpx.Response: + """Create a mock OCS response for a Nextcloud Talk chat message. + + Args: + message_id: Numeric message ID + token: Conversation token the message belongs to + text: Message body + actor_id: User ID of the message author + **kwargs: Additional message fields (override defaults) + + Returns: + Mock httpx.Response with message data wrapped in OCS format. + """ + message_data = { + "id": message_id, + "token": token, + "actorType": "users", + "actorId": actor_id, + "actorDisplayName": "Test User", + "timestamp": 1700000000, + "systemMessage": "", + "messageType": "comment", + "message": text, + "messageParameters": {}, + **kwargs, + } + + ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": message_data}} + return create_mock_response(status_code=200, json_data=ocs_response) + + def create_mock_tables_list_response( tables: list[dict] = None, ) -> httpx.Response: diff --git a/tests/client/talk/test_talk_api.py b/tests/client/talk/test_talk_api.py new file mode 100644 index 00000000..74f2b341 --- /dev/null +++ b/tests/client/talk/test_talk_api.py @@ -0,0 +1,386 @@ +"""Unit tests for the Nextcloud Talk (spreed) HTTP client.""" + +import logging + +import httpx +import pytest + +from nextcloud_mcp_server.client.talk import TalkClient +from nextcloud_mcp_server.models.talk import ( + TalkConversation, + TalkMessage, + TalkParticipant, +) +from tests.client.conftest import ( + create_mock_error_response, + create_mock_response, + create_mock_talk_message_response, + create_mock_talk_room_response, +) + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.unit + + +# Conversation tests + + +async def test_talk_list_conversations(mocker): + """list_conversations parses the OCS-wrapped room list.""" + mock_response = create_mock_response( + status_code=200, + json_data={ + "ocs": { + "meta": {"status": "ok"}, + "data": [ + { + "id": 1, + "token": "abc", + "type": 2, + "name": "Group 1", + "displayName": "Group 1", + "lastMessage": [], + }, + { + "id": 2, + "token": "def", + "type": 3, + "name": "Public Room", + "displayName": "Public Room", + "lastMessage": [], + }, + ], + } + }, + ) + + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + rooms = await client.list_conversations() + + assert len(rooms) == 2 + assert all(isinstance(r, TalkConversation) for r in rooms) + assert rooms[0].token == "abc" + assert rooms[1].type == 3 + + mock_make_request.assert_called_once() + call_args = mock_make_request.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/ocs/v2.php/apps/spreed/api/v4/room" + # noStatusUpdate defaults to True + assert call_args[1]["params"]["noStatusUpdate"] == 1 + + +async def test_talk_list_conversations_with_modified_since(mocker): + """modifiedSince and includeStatus are forwarded as query params.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + await client.list_conversations(modified_since=1700000000, include_status=True) + + params = mock_make_request.call_args[1]["params"] + assert params["modifiedSince"] == 1700000000 + assert params["includeStatus"] == "true" + + +async def test_talk_get_conversation(mocker): + """get_conversation parses the OCS-wrapped room object.""" + mock_response = create_mock_talk_room_response( + conversation_id=42, token="xyz", name="My Room" + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + room = await client.get_conversation("xyz") + + assert isinstance(room, TalkConversation) + assert room.id == 42 + assert room.token == "xyz" + assert room.name == "My Room" + + assert mock_make_request.call_args[0][0] == "GET" + assert "/api/v4/room/xyz" in mock_make_request.call_args[0][1] + + +async def test_talk_get_conversation_not_found(mocker): + """A 404 from spreed propagates as HTTPStatusError.""" + error_response = create_mock_error_response(404, "Conversation not found") + mock_make_request = mocker.patch.object(TalkClient, "_make_request") + mock_make_request.side_effect = httpx.HTTPStatusError( + "404 Not Found", + request=httpx.Request("GET", "http://test.local"), + response=error_response, + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + with pytest.raises(httpx.HTTPStatusError) as excinfo: + await client.get_conversation("nope") + assert excinfo.value.response.status_code == 404 + + +async def test_talk_create_conversation(mocker): + """create_conversation builds the right POST body.""" + mock_response = create_mock_talk_room_response( + conversation_id=10, token="new", name="New Room" + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + room = await client.create_conversation(room_name="New Room") + + assert room.token == "new" + call_args = mock_make_request.call_args + assert call_args[0][0] == "POST" + assert call_args[1]["json"] == {"roomType": 2, "roomName": "New Room"} + + +async def test_talk_delete_conversation(mocker): + """delete_conversation issues DELETE on the room URL.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + await client.delete_conversation("doomed") + + call_args = mock_make_request.call_args + assert call_args[0][0] == "DELETE" + assert "/api/v4/room/doomed" in call_args[0][1] + + +# Chat tests + + +async def test_talk_get_messages(mocker): + """get_messages parses the OCS-wrapped message list.""" + mock_response = create_mock_response( + status_code=200, + headers={"X-Chat-Last-Given": "100"}, + json_data={ + "ocs": { + "meta": {"status": "ok"}, + "data": [ + { + "id": 101, + "token": "abc", + "actorType": "users", + "actorId": "alice", + "actorDisplayName": "Alice", + "timestamp": 1700000000, + "messageType": "comment", + "message": "Hi", + }, + { + "id": 102, + "token": "abc", + "actorType": "users", + "actorId": "bob", + "actorDisplayName": "Bob", + "timestamp": 1700000010, + "messageType": "comment", + "message": "Hello", + }, + ], + } + }, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + messages, last_given = await client.get_messages("abc", limit=20) + + assert len(messages) == 2 + assert all(isinstance(m, TalkMessage) for m in messages) + assert messages[0].id == 101 + assert messages[1].actorId == "bob" + assert last_given == 100 + + call_args = mock_make_request.call_args + assert call_args[0][0] == "GET" + assert "/chat/abc" in call_args[0][1] + params = call_args[1]["params"] + assert params["limit"] == 20 + # Defaults: not look_into_future, not set_read_marker + assert params["lookIntoFuture"] == 0 + assert params["setReadMarker"] == 0 + + +async def test_talk_get_messages_pagination_cursor(mocker): + """last_known_message_id is forwarded as the spreed cursor param.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + messages, last_given = await client.get_messages( + "abc", last_known_message_id=500, include_last_known=True + ) + + assert messages == [] + assert last_given is None # No header on this response + + params = mock_make_request.call_args[1]["params"] + assert params["lastKnownMessageId"] == 500 + assert params["includeLastKnown"] == 1 + + +async def test_talk_send_message(mocker): + """send_message posts the message text and parses the response.""" + mock_response = create_mock_talk_message_response( + message_id=999, token="abc", text="Posted from MCP" + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + posted = await client.send_message("abc", "Posted from MCP") + + assert isinstance(posted, TalkMessage) + assert posted.id == 999 + assert posted.message == "Posted from MCP" + + call_args = mock_make_request.call_args + assert call_args[0][0] == "POST" + assert "/chat/abc" in call_args[0][1] + body = call_args[1]["json"] + assert body["message"] == "Posted from MCP" + # Optional fields are not sent unless explicitly set + assert "replyTo" not in body + assert "referenceId" not in body + assert "silent" not in body + + +async def test_talk_send_message_with_reference_id_and_reply(mocker): + """send_message forwards reply_to, reference_id, silent.""" + mock_response = create_mock_talk_message_response( + message_id=1000, token="abc", text="A reply" + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + await client.send_message( + "abc", + "A reply", + reply_to=999, + reference_id="dedupe-token", + silent=True, + ) + + body = mock_make_request.call_args[1]["json"] + assert body == { + "message": "A reply", + "replyTo": 999, + "referenceId": "dedupe-token", + "silent": True, + } + + +async def test_talk_mark_as_read_no_message(mocker): + """mark_as_read with no message ID sends an empty body.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + await client.mark_as_read("abc") + + call_args = mock_make_request.call_args + assert call_args[0][0] == "POST" + assert "/chat/abc/read" in call_args[0][1] + assert call_args[1]["json"] == {} + + +async def test_talk_mark_as_read_with_message(mocker): + """mark_as_read sends lastReadMessage when provided.""" + mock_response = create_mock_response( + status_code=200, + json_data={"ocs": {"meta": {"status": "ok"}, "data": []}}, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + await client.mark_as_read("abc", last_read_message=500) + + assert mock_make_request.call_args[1]["json"] == {"lastReadMessage": 500} + + +# Participant tests + + +async def test_talk_list_participants(mocker): + """list_participants parses the OCS-wrapped participant list.""" + mock_response = create_mock_response( + status_code=200, + json_data={ + "ocs": { + "meta": {"status": "ok"}, + "data": [ + { + "attendeeId": 1, + "actorType": "users", + "actorId": "alice", + "displayName": "Alice", + "participantType": 1, + "inCall": 0, + "lastPing": 1700000000, + }, + { + "attendeeId": 2, + "actorType": "users", + "actorId": "bob", + "displayName": "Bob", + "participantType": 3, + "inCall": 0, + "lastPing": 1700000000, + }, + ], + } + }, + ) + mock_make_request = mocker.patch.object( + TalkClient, "_make_request", return_value=mock_response + ) + + client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + participants = await client.list_participants("abc") + + assert len(participants) == 2 + assert all(isinstance(p, TalkParticipant) for p in participants) + assert participants[0].actorId == "alice" + assert participants[1].participantType == 3 + + call_args = mock_make_request.call_args + assert call_args[0][0] == "GET" + assert "/api/v4/room/abc/participants" in call_args[0][1] diff --git a/tests/conftest.py b/tests/conftest.py index a7a3ccfc..e8aade3f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,7 +38,8 @@ DEFAULT_FULL_SCOPES = ( "deck.read deck.write " "tables.read tables.write " "files.read files.write " - "sharing.read sharing.write" + "sharing.read sharing.write " + "talk.read talk.write" ) # Read-only scopes (all read scopes across apps) - should match DEFAULT_FULL_SCOPES read portion @@ -52,7 +53,8 @@ DEFAULT_READ_SCOPES = ( "deck.read " "tables.read " "files.read " - "sharing.read" + "sharing.read " + "talk.read" ) # Write-only scopes (all write scopes across apps) - should match DEFAULT_FULL_SCOPES write portion @@ -66,7 +68,8 @@ DEFAULT_WRITE_SCOPES = ( "deck.write " "tables.write " "files.write " - "sharing.write" + "sharing.write " + "talk.write" ) @@ -916,6 +919,43 @@ async def temporary_board_with_card( logger.error(f"Unexpected error deleting temporary card {card.id}: {e}") +@pytest.fixture +async def temporary_conversation(nc_client: NextcloudClient): + """Create a temporary Talk conversation and clean it up after the test. + + Yields a dict with the room ``token``, ``id``, and ``name``. + """ + unique_suffix = uuid.uuid4().hex[:8] + room_name = f"MCP Test Room {unique_suffix}" + token = None + + logger.info(f"Creating temporary Talk conversation: {room_name}") + try: + room = await nc_client.talk.create_conversation(room_name=room_name) + token = room.token + logger.info(f"Temporary Talk conversation created (token={token})") + yield {"token": token, "id": room.id, "name": room_name} + + finally: + if token: + logger.info(f"Cleaning up temporary Talk conversation token={token}") + try: + await nc_client.talk.delete_conversation(token) + logger.info(f"Successfully deleted Talk conversation token={token}") + except HTTPStatusError as e: + if e.response.status_code not in [404, 403]: + logger.error(f"HTTP error deleting Talk conversation {token}: {e}") + else: + logger.warning( + f"Talk conversation {token} already deleted or access denied " + f"({e.response.status_code})." + ) + except Exception as e: + logger.error( + f"Unexpected error deleting Talk conversation {token}: {e}" + ) + + @pytest.fixture(scope="session") def shared_test_calendar_name(): """Unique calendar name for the entire test session.""" diff --git a/tests/server/test_talk_mcp.py b/tests/server/test_talk_mcp.py new file mode 100644 index 00000000..d5d63214 --- /dev/null +++ b/tests/server/test_talk_mcp.py @@ -0,0 +1,143 @@ +"""Integration tests for the Nextcloud Talk (spreed) MCP tools.""" + +import json +import logging + +import pytest +from mcp import ClientSession + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + + +EXPECTED_TALK_TOOLS = { + "talk_list_conversations", + "talk_get_conversation", + "talk_get_messages", + "talk_list_participants", + "talk_send_message", + "talk_mark_as_read", +} + + +async def test_talk_mcp_connectivity(nc_mcp_client: ClientSession): + """All six Talk tools should be registered with the MCP server.""" + tools = await nc_mcp_client.list_tools() + tool_names = {tool.name for tool in tools.tools} + + missing = EXPECTED_TALK_TOOLS - tool_names + assert not missing, f"Missing Talk tools: {missing}" + + +async def test_talk_send_and_read_workflow( + nc_mcp_client: ClientSession, + nc_client: NextcloudClient, + temporary_conversation: dict, +): + """End-to-end: post a message via MCP, read it back, mark read.""" + token = temporary_conversation["token"] + + # 1. Send a message via MCP + send_result = await nc_mcp_client.call_tool( + "talk_send_message", + {"token": token, "message": "Hello from MCP integration test"}, + ) + assert send_result.isError is False, ( + f"talk_send_message failed: {send_result.content}" + ) + send_payload = json.loads(send_result.content[0].text) + assert send_payload["success"] is True + posted = send_payload["message"] + assert posted["message"] == "Hello from MCP integration test" + assert posted["token"] == token + posted_id = posted["id"] + logger.info(f"Posted message id={posted_id} into token={token}") + + # 2. Cross-check via direct client + direct_messages, _ = await nc_client.talk.get_messages(token, limit=10) + direct_ids = [m.id for m in direct_messages] + assert posted_id in direct_ids, "Posted message not visible via direct client" + + # 3. Read messages via MCP + get_result = await nc_mcp_client.call_tool( + "talk_get_messages", {"token": token, "limit": 10} + ) + assert get_result.isError is False, ( + f"talk_get_messages failed: {get_result.content}" + ) + get_payload = json.loads(get_result.content[0].text) + assert get_payload["conversation_token"] == token + listed_ids = [m["id"] for m in get_payload["results"]] + assert posted_id in listed_ids, "Posted message not in MCP get_messages results" + + # 4. Mark conversation as read up to that message + mark_result = await nc_mcp_client.call_tool( + "talk_mark_as_read", + {"token": token, "last_read_message": posted_id}, + ) + assert mark_result.isError is False, ( + f"talk_mark_as_read failed: {mark_result.content}" + ) + mark_payload = json.loads(mark_result.content[0].text) + assert mark_payload["success"] is True + assert mark_payload["conversation_token"] == token + assert mark_payload["last_read_message"] == posted_id + + +async def test_talk_list_conversations_includes_temp_room( + nc_mcp_client: ClientSession, temporary_conversation: dict +): + """Newly created conversation should appear in talk_list_conversations.""" + token = temporary_conversation["token"] + + list_result = await nc_mcp_client.call_tool("talk_list_conversations", {}) + assert list_result.isError is False, ( + f"talk_list_conversations failed: {list_result.content}" + ) + payload = json.loads(list_result.content[0].text) + tokens = [r["token"] for r in payload["results"]] + assert token in tokens, "Temporary conversation not found in list" + + +async def test_talk_get_conversation( + nc_mcp_client: ClientSession, temporary_conversation: dict +): + """talk_get_conversation returns the same room we created.""" + token = temporary_conversation["token"] + name = temporary_conversation["name"] + + result = await nc_mcp_client.call_tool("talk_get_conversation", {"token": token}) + assert result.isError is False, f"talk_get_conversation failed: {result.content}" + payload = json.loads(result.content[0].text) + conversation = payload["conversation"] + assert conversation["token"] == token + assert conversation["name"] == name + + +async def test_talk_list_participants( + nc_mcp_client: ClientSession, temporary_conversation: dict +): + """talk_list_participants returns the room creator as a participant.""" + token = temporary_conversation["token"] + + result = await nc_mcp_client.call_tool("talk_list_participants", {"token": token}) + assert result.isError is False, f"talk_list_participants failed: {result.content}" + payload = json.loads(result.content[0].text) + assert payload["conversation_token"] == token + actor_ids = [p["actorId"] for p in payload["results"]] + # The user that created the room is always a participant. + assert len(actor_ids) >= 1 + + +async def test_talk_send_message_validation_empty_text( + nc_mcp_client: ClientSession, temporary_conversation: dict +): + """Empty message text is rejected client-side.""" + token = temporary_conversation["token"] + + result = await nc_mcp_client.call_tool( + "talk_send_message", {"token": token, "message": ""} + ) + assert result.isError is True, "Expected validation error for empty message"