Skip to content

Commit f392aa7

Browse files
authored
Merge pull request #3 from cloud-py-api/feature/p2-talk-tools
Add Nextcloud Talk tools
2 parents e1a7b94 + 6587e9f commit f392aa7

6 files changed

Lines changed: 939 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ jobs:
106106
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set ratelimit_protection_enabled --value=false --type=boolean"
107107
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean"
108108
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set loglevel --value=0 --type=integer"
109+
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ app:install spreed" || echo "spreed already installed"
110+
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ app:install admin_notifications" || echo "admin_notifications already installed"
109111
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ app:list" | head -30
110112
111113
- name: Run integration tests

PROGRESS.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Nextcloud MCP Server — Development Progress
22

3-
## Current Phase: 1Core
3+
## Current Phase: 2Communication
44

55
### Completed
66
- [x] Project scaffold: pyproject.toml, src layout, config, permissions, client (2026-03-20)
@@ -11,7 +11,9 @@
1111
- [x] Unit tests: permissions, config (2026-03-20)
1212
- [x] Integration tests: files lifecycle, users (2026-03-20)
1313
- [x] CI pipeline: lint + unit tests + integration tests with real Nextcloud (2026-03-20)
14-
- [x] Notifications tools: list_notifications, dismiss_notification (2026-03-21)
14+
- [x] Notifications tools: list_notifications, dismiss_notification, dismiss_all_notifications (2026-03-21)
15+
- [x] Test suite overhaul: MCP tool-level integration tests (2026-03-21)
16+
- [x] Talk tools: list_conversations, get_conversation, get_messages, get_participants, send_message, create_conversation, delete_message, leave_conversation (2026-03-22)
1517

1618
### In Progress
1719
- [ ] Activity tools: get_activity
@@ -21,15 +23,29 @@
2123
(none)
2224

2325
### Next Up
24-
- Phase 2: Talk tools (list_conversations, get_messages, send_message)
26+
- Talk polls: get_poll, create_poll, vote_poll
27+
- Announcement Center
28+
- Files Sharing, Trashbin, Versions
2529
- Improve error handling and error messages
26-
- Add more integration test coverage
2730

2831
## Phases
2932

3033
| Phase | Focus | Status |
3134
|-------|-------|--------|
3235
| 1 | Core (Files, Users, Notifications, Activity) | In Progress |
33-
| 2 | Communication (Talk) | Not Started |
36+
| 2 | Communication (Talk) | In Progress |
3437
| 3 | Collaboration (Shares, Calendar, Contacts, Deck) | Not Started |
3538
| 4 | Advanced (Search, Status, Apps) | Not Started |
39+
40+
## Test Coverage
41+
42+
| Module | Tools | Integration Tests |
43+
|--------|-------|-------------------|
44+
| Files | 6 | 20 |
45+
| Users | 3 | 10 |
46+
| Notifications | 3 | 12 |
47+
| Talk | 8 | 44 |
48+
| Server || 5 |
49+
| Permissions || 16 |
50+
| Errors || 10 |
51+
| **Total** | **20** | **127** |

src/nextcloud_mcp/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .config import Config
77
from .permissions import set_permission_level
88
from .state import get_client, get_config, set_state
9-
from .tools import files, notifications, users
9+
from .tools import files, notifications, talk, users
1010

1111
__all__ = ["create_server", "get_client", "get_config"]
1212

@@ -36,6 +36,7 @@ def create_server(config: Config | None = None) -> FastMCP:
3636

3737
files.register(mcp)
3838
notifications.register(mcp)
39+
talk.register(mcp)
3940
users.register(mcp)
4041

4142
return mcp

src/nextcloud_mcp/tools/talk.py

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
"""Nextcloud Talk tools — conversations, messages, and participants via OCS API."""
2+
3+
import json
4+
from typing import Any
5+
6+
from mcp.server.fastmcp import FastMCP
7+
8+
from ..permissions import PermissionLevel, require_permission
9+
from ..state import get_client
10+
11+
# Conversation type IDs used by Nextcloud Talk
12+
_CONVERSATION_TYPES: dict[int, str] = {
13+
1: "one-to-one",
14+
2: "group",
15+
3: "public",
16+
4: "changelog",
17+
5: "former-one-to-one",
18+
6: "note-to-self",
19+
}
20+
21+
# Room types accepted when creating conversations
22+
_VALID_ROOM_TYPES = {2: "group", 3: "public"}
23+
24+
# Participant type IDs used by Nextcloud Talk
25+
_PARTICIPANT_TYPES: dict[int, str] = {
26+
1: "owner",
27+
2: "moderator",
28+
3: "user",
29+
4: "guest",
30+
5: "user-following-public-link",
31+
6: "guest-moderator",
32+
}
33+
34+
35+
def _format_conversation(room: dict[str, Any]) -> dict[str, Any]:
36+
"""Extract the most useful fields from a raw room object."""
37+
return {
38+
"token": room["token"],
39+
"type": _CONVERSATION_TYPES.get(room.get("type", 0), f"unknown({room.get('type')})"),
40+
"name": room.get("displayName", room.get("name", "")),
41+
"description": room.get("description", ""),
42+
"read_only": room.get("readOnly", 0) == 1,
43+
"has_call": room.get("hasCall", False),
44+
"unread_messages": room.get("unreadMessages", 0),
45+
"unread_mention": room.get("unreadMention", False),
46+
"last_activity": room.get("lastActivity", 0),
47+
"is_favorite": room.get("isFavorite", False),
48+
"participant_count": room.get("participantCount", 0),
49+
"can_leave": room.get("canLeaveConversation", False),
50+
"can_delete": room.get("canDeleteConversation", False),
51+
}
52+
53+
54+
def _format_message_compact(msg: dict[str, Any]) -> str:
55+
"""Format a message as a compact single line: [id] author: text."""
56+
msg_id = msg.get("id", 0)
57+
author = msg.get("actorDisplayName", "unknown")
58+
text = msg.get("message", "")
59+
return f"[{msg_id}] {author}: {text}"
60+
61+
62+
def _format_message_full(msg: dict[str, Any]) -> dict[str, Any]:
63+
"""Extract the most useful fields from a raw message object."""
64+
return {
65+
"id": msg["id"],
66+
"actor_type": msg.get("actorType", ""),
67+
"actor_id": msg.get("actorId", ""),
68+
"actor_display_name": msg.get("actorDisplayName", ""),
69+
"timestamp": msg.get("timestamp", 0),
70+
"message": msg.get("message", ""),
71+
"message_type": msg.get("messageType", ""),
72+
"system_message": msg.get("systemMessage", ""),
73+
"is_replyable": msg.get("isReplyable", False),
74+
}
75+
76+
77+
def _format_participant(p: dict[str, Any]) -> dict[str, Any]:
78+
"""Extract the most useful fields from a raw participant object."""
79+
return {
80+
"attendee_id": p.get("attendeeId", 0),
81+
"actor_type": p.get("actorType", ""),
82+
"actor_id": p.get("actorId", ""),
83+
"display_name": p.get("displayName", ""),
84+
"participant_type": _PARTICIPANT_TYPES.get(p.get("participantType", 0), f"unknown({p.get('participantType')})"),
85+
"in_call": p.get("inCall", 0) > 0,
86+
}
87+
88+
89+
def _register_read_tools(mcp: FastMCP) -> None:
90+
"""Register read-only Talk tools."""
91+
92+
@mcp.tool()
93+
@require_permission(PermissionLevel.READ)
94+
async def list_conversations(include_notifications_disabled: bool = False) -> str:
95+
"""List all Talk conversations the current user is part of.
96+
97+
Returns conversations sorted by last activity (newest first).
98+
Each conversation includes: token (unique ID for API calls), type,
99+
name, unread counts, and permissions.
100+
101+
Args:
102+
include_notifications_disabled: If true, also return conversations where
103+
notifications are disabled (default: false).
104+
105+
Returns:
106+
JSON list of conversation objects.
107+
"""
108+
client = get_client()
109+
params: dict[str, str] = {}
110+
if not include_notifications_disabled:
111+
params["noStatusUpdate"] = "0"
112+
data = await client.ocs_get("apps/spreed/api/v4/room", params=params)
113+
conversations = [_format_conversation(room) for room in data]
114+
return json.dumps(conversations, indent=2, default=str)
115+
116+
@mcp.tool()
117+
@require_permission(PermissionLevel.READ)
118+
async def get_conversation(token: str) -> str:
119+
"""Get details about a specific Talk conversation.
120+
121+
Args:
122+
token: The conversation token (short alphanumeric ID, e.g. "abc12xyz").
123+
Use list_conversations to find tokens.
124+
125+
Returns:
126+
JSON object with conversation details.
127+
"""
128+
client = get_client()
129+
data = await client.ocs_get(f"apps/spreed/api/v4/room/{token}")
130+
return json.dumps(_format_conversation(data), indent=2, default=str)
131+
132+
@mcp.tool()
133+
@require_permission(PermissionLevel.READ)
134+
async def get_messages(
135+
token: str,
136+
limit: int = 50,
137+
before_message_id: int = 0,
138+
include_system: bool = False,
139+
) -> str:
140+
"""Get chat messages from a Talk conversation.
141+
142+
Returns messages in reverse chronological order (newest first).
143+
Uses a compact format: "[id] author: message text" — one line per message.
144+
145+
IMPORTANT: Start with a small limit (20-50). If you need more context,
146+
use before_message_id with the oldest message ID from the previous call
147+
to paginate backwards through the history.
148+
149+
Args:
150+
token: The conversation token. Use list_conversations to find tokens.
151+
limit: Maximum number of messages to return (1-200, default: 50).
152+
Start small to avoid exceeding response size limits.
153+
before_message_id: Fetch messages older than this message ID (for pagination).
154+
Use the smallest message ID from a previous call.
155+
Default 0 means start from the newest message.
156+
include_system: Include system messages like "User joined",
157+
"Conversation created" (default: false — only chat messages).
158+
159+
Returns:
160+
Compact text with one message per line: "[id] author: message".
161+
The last line shows pagination info if more messages may exist.
162+
"""
163+
client = get_client()
164+
limit = max(1, min(200, limit))
165+
params: dict[str, str] = {
166+
"lookIntoFuture": "0",
167+
"limit": str(limit),
168+
"setReadMarker": "0",
169+
}
170+
if before_message_id:
171+
params["lastKnownMessageId"] = str(before_message_id)
172+
data = await client.ocs_get(f"apps/spreed/api/v1/chat/{token}", params=params)
173+
174+
if not include_system:
175+
data = [m for m in data if not m.get("systemMessage")]
176+
177+
lines = [_format_message_compact(msg) for msg in data]
178+
179+
if data:
180+
oldest_id = min(m["id"] for m in data)
181+
lines.append(f"\n--- {len(data)} messages. For older messages, call with before_message_id={oldest_id} ---")
182+
183+
return "\n".join(lines)
184+
185+
@mcp.tool()
186+
@require_permission(PermissionLevel.READ)
187+
async def get_participants(token: str) -> str:
188+
"""List participants in a Talk conversation.
189+
190+
Args:
191+
token: The conversation token. Use list_conversations to find tokens.
192+
193+
Returns:
194+
JSON list of participant objects, each with: attendee_id,
195+
actor_id, display_name, participant_type (owner/moderator/user/guest),
196+
in_call status.
197+
"""
198+
client = get_client()
199+
data = await client.ocs_get(f"apps/spreed/api/v4/room/{token}/participants")
200+
participants = [_format_participant(p) for p in data]
201+
return json.dumps(participants, indent=2, default=str)
202+
203+
204+
def _register_write_tools(mcp: FastMCP) -> None:
205+
"""Register write and destructive Talk tools."""
206+
207+
@mcp.tool()
208+
@require_permission(PermissionLevel.WRITE)
209+
async def send_message(token: str, message: str, reply_to: int = 0) -> str:
210+
"""Send a chat message to a Talk conversation.
211+
212+
Supports Markdown formatting. Messages can be up to 32000 characters.
213+
Use @mention syntax to mention users: @"user-id" or @"display name".
214+
215+
Args:
216+
token: The conversation token. Use list_conversations to find tokens.
217+
message: The message text to send (supports Markdown).
218+
reply_to: Optional message ID to reply to (default: 0 = not a reply).
219+
220+
Returns:
221+
JSON object of the sent message with its assigned ID.
222+
"""
223+
client = get_client()
224+
post_data: dict[str, Any] = {"message": message}
225+
if reply_to:
226+
post_data["replyTo"] = reply_to
227+
data = await client.ocs_post(f"apps/spreed/api/v1/chat/{token}", data=post_data)
228+
return json.dumps(_format_message_full(data), indent=2, default=str)
229+
230+
@mcp.tool()
231+
@require_permission(PermissionLevel.WRITE)
232+
async def create_conversation(
233+
room_type: int,
234+
name: str,
235+
invite: str = "",
236+
) -> str:
237+
"""Create a new Talk conversation.
238+
239+
Args:
240+
room_type: 2 for group conversation, 3 for public conversation.
241+
Group conversations are invite-only.
242+
Public conversations can be joined via link.
243+
name: Display name for the conversation.
244+
invite: Optional user ID to invite (for group conversations).
245+
246+
Returns:
247+
JSON object with the created conversation details, including its token.
248+
"""
249+
if room_type not in _VALID_ROOM_TYPES:
250+
valid = ", ".join(f"{k} ({v})" for k, v in _VALID_ROOM_TYPES.items())
251+
raise ValueError(f"Invalid room_type {room_type}. Valid types: {valid}")
252+
client = get_client()
253+
post_data: dict[str, Any] = {"roomType": room_type, "roomName": name}
254+
if invite:
255+
post_data["invite"] = invite
256+
data = await client.ocs_post("apps/spreed/api/v4/room", data=post_data)
257+
return json.dumps(_format_conversation(data), indent=2, default=str)
258+
259+
@mcp.tool()
260+
@require_permission(PermissionLevel.DESTRUCTIVE)
261+
async def delete_message(token: str, message_id: int) -> str:
262+
"""Delete a chat message from a Talk conversation.
263+
264+
Only the message author or a moderator can delete a message.
265+
The message is replaced with "Message deleted" in the conversation.
266+
267+
Args:
268+
token: The conversation token.
269+
message_id: The ID of the message to delete.
270+
271+
Returns:
272+
Confirmation message.
273+
"""
274+
client = get_client()
275+
await client.ocs_delete(f"apps/spreed/api/v1/chat/{token}/{message_id}")
276+
return f"Message {message_id} deleted from conversation {token}."
277+
278+
@mcp.tool()
279+
@require_permission(PermissionLevel.DESTRUCTIVE)
280+
async def leave_conversation(token: str) -> str:
281+
"""Leave a Talk conversation.
282+
283+
After leaving, the user will no longer receive notifications or see
284+
the conversation in their list. For group conversations, the user
285+
can be re-invited. For one-to-one conversations, this removes the
286+
conversation permanently for the user.
287+
288+
Args:
289+
token: The conversation token of the conversation to leave.
290+
291+
Returns:
292+
Confirmation message.
293+
"""
294+
client = get_client()
295+
await client.ocs_delete(f"apps/spreed/api/v4/room/{token}/participants/self")
296+
return f"Left conversation {token}."
297+
298+
299+
def register(mcp: FastMCP) -> None:
300+
"""Register Talk tools with the MCP server."""
301+
_register_read_tools(mcp)
302+
_register_write_tools(mcp)

0 commit comments

Comments
 (0)