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
+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."""