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:
co-authored by
Claude Opus 4.7
parent
a9c5759869
commit
69814f30e3
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user