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
+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]
+43 -3
View File
@@ -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."""
+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"