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) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-29 23:19:57 +02:00
co-authored by Claude Opus 4.7
parent a9c5759869
commit 69814f30e3
12 changed files with 1278 additions and 3 deletions
+1
View File
@@ -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!
+9
View File
@@ -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
@@ -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.",
]
)
+2
View File
@@ -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)
+238
View File
@@ -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]
+152
View File
@@ -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",
)
+3
View File
@@ -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",
]
+220
View File
@@ -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,
)
+79
View File
@@ -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:
+386
View File
@@ -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]
+40
View File
@@ -39,6 +39,7 @@ DEFAULT_FULL_SCOPES = (
"tables.read tables.write "
"files.read files.write "
"sharing.read sharing.write "
"talk.read talk.write"
)
# Read-only scopes (all read scopes across apps) - should match DEFAULT_FULL_SCOPES read portion
@@ -53,6 +54,7 @@ DEFAULT_READ_SCOPES = (
"tables.read "
"files.read "
"sharing.read "
"talk.read"
)
# Write-only scopes (all write scopes across apps) - should match DEFAULT_FULL_SCOPES write portion
@@ -67,6 +69,7 @@ DEFAULT_WRITE_SCOPES = (
"tables.write "
"files.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."""
+143
View File
@@ -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"