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
@@ -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,
)