Skip to content

Commit 8f96a92

Browse files
committed
Add MCP ToolAnnotations to all 35 tools (readOnlyHint, destructiveHint, idempotentHint)
1 parent 28f5e5a commit 8f96a92

9 files changed

Lines changed: 63 additions & 35 deletions

File tree

src/nextcloud_mcp/annotations.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Shared MCP ToolAnnotations constants for Nextcloud tools."""
2+
3+
from mcp.types import ToolAnnotations
4+
5+
READONLY = ToolAnnotations(readOnlyHint=True)
6+
ADDITIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False)
7+
ADDITIVE_IDEMPOTENT = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True)
8+
DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True)
9+
DESTRUCTIVE_NON_IDEMPOTENT = ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=False)

src/nextcloud_mcp/tools/activity.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from mcp.server.fastmcp import FastMCP
77

8+
from ..annotations import READONLY
89
from ..permissions import PermissionLevel, require_permission
910
from ..state import get_client
1011

@@ -52,7 +53,7 @@ def _format_activity(a: dict[str, Any]) -> dict[str, Any]:
5253
def register(mcp: FastMCP) -> None:
5354
"""Register activity tools with the MCP server."""
5455

55-
@mcp.tool()
56+
@mcp.tool(annotations=READONLY)
5657
@require_permission(PermissionLevel.READ)
5758
async def get_activity(
5859
activity_filter: str = "all",

src/nextcloud_mcp/tools/comments.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from mcp.server.fastmcp import FastMCP
99

10+
from ..annotations import ADDITIVE, ADDITIVE_IDEMPOTENT, DESTRUCTIVE, READONLY
1011
from ..permissions import PermissionLevel, require_permission
1112
from ..state import get_client
1213

@@ -96,7 +97,7 @@ def _parse_comments_xml(xml_text: str) -> list[dict[str, Any]]:
9697

9798

9899
def _register_read_tools(mcp: FastMCP) -> None:
99-
@mcp.tool()
100+
@mcp.tool(annotations=READONLY)
100101
@require_permission(PermissionLevel.READ)
101102
async def list_comments(file_id: int, limit: int = 20, offset: int = 0) -> str:
102103
"""List comments on a file.
@@ -137,7 +138,7 @@ async def list_comments(file_id: int, limit: int = 20, offset: int = 0) -> str:
137138

138139

139140
def _register_write_tools(mcp: FastMCP) -> None:
140-
@mcp.tool()
141+
@mcp.tool(annotations=ADDITIVE)
141142
@require_permission(PermissionLevel.WRITE)
142143
async def add_comment(file_id: int, message: str) -> str:
143144
"""Add a comment to a file.
@@ -169,7 +170,7 @@ async def add_comment(file_id: int, message: str) -> str:
169170
comment_id = location.rstrip("/").split("/")[-1] if location else "unknown"
170171
return json.dumps({"id": comment_id, "message": message}, indent=2)
171172

172-
@mcp.tool()
173+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
173174
@require_permission(PermissionLevel.WRITE)
174175
async def edit_comment(file_id: int, comment_id: int, message: str) -> str:
175176
"""Edit a comment on a file.
@@ -207,7 +208,7 @@ async def edit_comment(file_id: int, comment_id: int, message: str) -> str:
207208

208209

209210
def _register_destructive_tools(mcp: FastMCP) -> None:
210-
@mcp.tool()
211+
@mcp.tool(annotations=DESTRUCTIVE)
211212
@require_permission(PermissionLevel.DESTRUCTIVE)
212213
async def delete_comment(file_id: int, comment_id: int) -> str:
213214
"""Delete a comment from a file.

src/nextcloud_mcp/tools/files.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55

66
from mcp.server.fastmcp import FastMCP
77

8+
from ..annotations import (
9+
ADDITIVE_IDEMPOTENT,
10+
DESTRUCTIVE,
11+
DESTRUCTIVE_NON_IDEMPOTENT,
12+
READONLY,
13+
)
814
from ..client import NextcloudClient
915
from ..permissions import PermissionLevel, require_permission
1016
from ..state import get_client, get_config
@@ -48,7 +54,7 @@ def _build_search_xml(user: str, query: str, path: str, limit: int, offset: int,
4854

4955

5056
def _register_read_tools(mcp: FastMCP) -> None:
51-
@mcp.tool()
57+
@mcp.tool(annotations=READONLY)
5258
@require_permission(PermissionLevel.READ)
5359
async def list_directory(path: str = "/") -> str:
5460
"""List files and folders in a Nextcloud directory.
@@ -67,7 +73,7 @@ async def list_directory(path: str = "/") -> str:
6773
entries = entries[1:]
6874
return json.dumps(entries, indent=2, default=str)
6975

70-
@mcp.tool()
76+
@mcp.tool(annotations=READONLY)
7177
@require_permission(PermissionLevel.READ)
7278
async def get_file(path: str) -> str:
7379
"""Read a file's content from Nextcloud.
@@ -88,7 +94,7 @@ async def get_file(path: str) -> str:
8894
except UnicodeDecodeError:
8995
return f"[Binary file, {len(content)} bytes. Use download tools for binary content.]"
9096

91-
@mcp.tool()
97+
@mcp.tool(annotations=READONLY)
9298
@require_permission(PermissionLevel.READ)
9399
async def search_files(
94100
query: str = "",
@@ -145,7 +151,7 @@ async def search_files(
145151

146152

147153
def _register_write_tools(mcp: FastMCP) -> None:
148-
@mcp.tool()
154+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
149155
@require_permission(PermissionLevel.WRITE)
150156
async def upload_file(path: str, content: str) -> str:
151157
"""Upload or overwrite a text file in Nextcloud.
@@ -163,7 +169,7 @@ async def upload_file(path: str, content: str) -> str:
163169
await client.dav_put(path, content.encode("utf-8"), content_type="text/plain; charset=utf-8")
164170
return f"File uploaded successfully: {path}"
165171

166-
@mcp.tool()
172+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
167173
@require_permission(PermissionLevel.WRITE)
168174
async def create_directory(path: str) -> str:
169175
"""Create a new directory in Nextcloud.
@@ -180,7 +186,7 @@ async def create_directory(path: str) -> str:
180186

181187

182188
def _register_destructive_tools(mcp: FastMCP) -> None:
183-
@mcp.tool()
189+
@mcp.tool(annotations=DESTRUCTIVE)
184190
@require_permission(PermissionLevel.DESTRUCTIVE)
185191
async def delete_file(path: str) -> str:
186192
"""Delete a file or directory from Nextcloud.
@@ -197,7 +203,7 @@ async def delete_file(path: str) -> str:
197203
await client.dav_delete(path)
198204
return f"Deleted: {path}"
199205

200-
@mcp.tool()
206+
@mcp.tool(annotations=DESTRUCTIVE_NON_IDEMPOTENT)
201207
@require_permission(PermissionLevel.DESTRUCTIVE)
202208
async def move_file(source: str, destination: str) -> str:
203209
"""Move or rename a file/directory in Nextcloud.

src/nextcloud_mcp/tools/notifications.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44

55
from mcp.server.fastmcp import FastMCP
66

7+
from ..annotations import DESTRUCTIVE, READONLY
78
from ..permissions import PermissionLevel, require_permission
89
from ..state import get_client
910

1011

1112
def register(mcp: FastMCP) -> None:
1213
"""Register notification tools with the MCP server."""
1314

14-
@mcp.tool()
15+
@mcp.tool(annotations=READONLY)
1516
@require_permission(PermissionLevel.READ)
1617
async def list_notifications() -> str:
1718
"""List all notifications for the current Nextcloud user.
@@ -29,7 +30,7 @@ async def list_notifications() -> str:
2930
)
3031
return json.dumps(data, indent=2, default=str)
3132

32-
@mcp.tool()
33+
@mcp.tool(annotations=DESTRUCTIVE)
3334
@require_permission(PermissionLevel.DESTRUCTIVE)
3435
async def dismiss_notification(notification_id: int) -> str:
3536
"""Dismiss (permanently delete) a single notification by its ID.
@@ -48,7 +49,7 @@ async def dismiss_notification(notification_id: int) -> str:
4849
)
4950
return f"Notification {notification_id} dismissed."
5051

51-
@mcp.tool()
52+
@mcp.tool(annotations=DESTRUCTIVE)
5253
@require_permission(PermissionLevel.DESTRUCTIVE)
5354
async def dismiss_all_notifications() -> str:
5455
"""Dismiss (permanently delete) ALL notifications for the current user.

src/nextcloud_mcp/tools/talk.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from mcp.server.fastmcp import FastMCP
77

8+
from ..annotations import ADDITIVE, ADDITIVE_IDEMPOTENT, DESTRUCTIVE, READONLY
89
from ..permissions import PermissionLevel, require_permission
910
from ..state import get_client
1011

@@ -125,7 +126,7 @@ def _format_participant(p: dict[str, Any]) -> dict[str, Any]:
125126
def _register_read_tools(mcp: FastMCP) -> None:
126127
"""Register read-only Talk tools."""
127128

128-
@mcp.tool()
129+
@mcp.tool(annotations=READONLY)
129130
@require_permission(PermissionLevel.READ)
130131
async def list_conversations(include_notifications_disabled: bool = False) -> str:
131132
"""List all Talk conversations the current user is part of.
@@ -149,7 +150,7 @@ async def list_conversations(include_notifications_disabled: bool = False) -> st
149150
conversations = [_format_conversation(room) for room in data]
150151
return json.dumps(conversations, indent=2, default=str)
151152

152-
@mcp.tool()
153+
@mcp.tool(annotations=READONLY)
153154
@require_permission(PermissionLevel.READ)
154155
async def get_conversation(token: str) -> str:
155156
"""Get details about a specific Talk conversation.
@@ -165,7 +166,7 @@ async def get_conversation(token: str) -> str:
165166
data = await client.ocs_get(f"apps/spreed/api/v4/room/{token}")
166167
return json.dumps(_format_conversation(data), indent=2, default=str)
167168

168-
@mcp.tool()
169+
@mcp.tool(annotations=READONLY)
169170
@require_permission(PermissionLevel.READ)
170171
async def get_messages(
171172
token: str,
@@ -218,7 +219,7 @@ async def get_messages(
218219

219220
return "\n".join(lines)
220221

221-
@mcp.tool()
222+
@mcp.tool(annotations=READONLY)
222223
@require_permission(PermissionLevel.READ)
223224
async def get_participants(token: str) -> str:
224225
"""List participants in a Talk conversation.
@@ -236,7 +237,7 @@ async def get_participants(token: str) -> str:
236237
participants = [_format_participant(p) for p in data]
237238
return json.dumps(participants, indent=2, default=str)
238239

239-
@mcp.tool()
240+
@mcp.tool(annotations=READONLY)
240241
@require_permission(PermissionLevel.READ)
241242
async def get_poll(token: str, poll_id: int) -> str:
242243
"""Get a poll from a Talk conversation.
@@ -264,7 +265,7 @@ async def get_poll(token: str, poll_id: int) -> str:
264265
def _register_poll_tools(mcp: FastMCP) -> None:
265266
"""Register poll-related Talk tools (write + destructive)."""
266267

267-
@mcp.tool()
268+
@mcp.tool(annotations=ADDITIVE)
268269
@require_permission(PermissionLevel.WRITE)
269270
async def create_poll(
270271
token: str,
@@ -304,7 +305,7 @@ async def create_poll(
304305
data = await client.ocs_post(f"apps/spreed/api/v1/poll/{token}", data=post_data)
305306
return json.dumps(_format_poll(data), indent=2, default=str)
306307

307-
@mcp.tool()
308+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
308309
@require_permission(PermissionLevel.WRITE)
309310
async def vote_poll(token: str, poll_id: int, option_ids: list[int]) -> str:
310311
"""Vote on a poll in a Talk conversation.
@@ -332,7 +333,7 @@ async def vote_poll(token: str, poll_id: int, option_ids: list[int]) -> str:
332333
data = await client.ocs_post(f"apps/spreed/api/v1/poll/{token}/{poll_id}", data=post_data)
333334
return json.dumps(_format_poll(data), indent=2, default=str)
334335

335-
@mcp.tool()
336+
@mcp.tool(annotations=DESTRUCTIVE)
336337
@require_permission(PermissionLevel.DESTRUCTIVE)
337338
async def close_poll(token: str, poll_id: int) -> str:
338339
"""Close a poll in a Talk conversation.
@@ -358,7 +359,7 @@ async def close_poll(token: str, poll_id: int) -> str:
358359
def _register_write_tools(mcp: FastMCP) -> None:
359360
"""Register write and destructive Talk tools for conversations and messages."""
360361

361-
@mcp.tool()
362+
@mcp.tool(annotations=ADDITIVE)
362363
@require_permission(PermissionLevel.WRITE)
363364
async def send_message(token: str, message: str, reply_to: int = 0) -> str:
364365
"""Send a chat message to a Talk conversation.
@@ -381,7 +382,7 @@ async def send_message(token: str, message: str, reply_to: int = 0) -> str:
381382
data = await client.ocs_post(f"apps/spreed/api/v1/chat/{token}", data=post_data)
382383
return json.dumps(_format_message_full(data), indent=2, default=str)
383384

384-
@mcp.tool()
385+
@mcp.tool(annotations=ADDITIVE)
385386
@require_permission(PermissionLevel.WRITE)
386387
async def create_conversation(
387388
room_type: int,
@@ -410,7 +411,7 @@ async def create_conversation(
410411
data = await client.ocs_post("apps/spreed/api/v4/room", data=post_data)
411412
return json.dumps(_format_conversation(data), indent=2, default=str)
412413

413-
@mcp.tool()
414+
@mcp.tool(annotations=DESTRUCTIVE)
414415
@require_permission(PermissionLevel.DESTRUCTIVE)
415416
async def delete_message(token: str, message_id: int) -> str:
416417
"""Delete a chat message from a Talk conversation.
@@ -429,7 +430,7 @@ async def delete_message(token: str, message_id: int) -> str:
429430
await client.ocs_delete(f"apps/spreed/api/v1/chat/{token}/{message_id}")
430431
return f"Message {message_id} deleted from conversation {token}."
431432

432-
@mcp.tool()
433+
@mcp.tool(annotations=DESTRUCTIVE)
433434
@require_permission(PermissionLevel.DESTRUCTIVE)
434435
async def leave_conversation(token: str) -> str:
435436
"""Leave a Talk conversation.

src/nextcloud_mcp/tools/user_status.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from mcp.server.fastmcp import FastMCP
77

8+
from ..annotations import ADDITIVE_IDEMPOTENT, READONLY
89
from ..client import NextcloudError
910
from ..permissions import PermissionLevel, require_permission
1011
from ..state import get_client, get_config
@@ -23,7 +24,7 @@ def _format_status(data: dict[str, Any]) -> dict[str, Any]:
2324

2425

2526
def _register_read_tools(mcp: FastMCP) -> None:
26-
@mcp.tool()
27+
@mcp.tool(annotations=READONLY)
2728
@require_permission(PermissionLevel.READ)
2829
async def get_user_status(user_id: str = "") -> str:
2930
"""Get the status of a Nextcloud user.
@@ -53,7 +54,7 @@ async def get_user_status(user_id: str = "") -> str:
5354

5455

5556
def _register_write_tools(mcp: FastMCP) -> None:
56-
@mcp.tool()
57+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
5758
@require_permission(PermissionLevel.WRITE)
5859
async def set_user_status(
5960
status_type: str = "",
@@ -102,7 +103,7 @@ async def set_user_status(
102103
)
103104
return json.dumps(_format_status(result), indent=2, default=str)
104105

105-
@mcp.tool()
106+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
106107
@require_permission(PermissionLevel.WRITE)
107108
async def clear_user_status() -> str:
108109
"""Clear the current user's status message and icon.

src/nextcloud_mcp/tools/users.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
from mcp.server.fastmcp import FastMCP
66

7+
from ..annotations import ADDITIVE, DESTRUCTIVE, READONLY
78
from ..permissions import PermissionLevel, require_permission
89
from ..state import get_client
910

1011

1112
def _register_read_tools(mcp: FastMCP) -> None:
12-
@mcp.tool()
13+
@mcp.tool(annotations=READONLY)
1314
@require_permission(PermissionLevel.READ)
1415
async def get_current_user() -> str:
1516
"""Get information about the currently authenticated Nextcloud user.
@@ -21,7 +22,7 @@ async def get_current_user() -> str:
2122
data = await client.ocs_get("cloud/user")
2223
return json.dumps(data, indent=2, default=str)
2324

24-
@mcp.tool()
25+
@mcp.tool(annotations=READONLY)
2526
@require_permission(PermissionLevel.READ)
2627
async def list_users(search: str = "", limit: int = 25, offset: int = 0) -> str:
2728
"""List Nextcloud users.
@@ -39,7 +40,7 @@ async def list_users(search: str = "", limit: int = 25, offset: int = 0) -> str:
3940
data = await client.ocs_get("cloud/users", params=params)
4041
return json.dumps(data, indent=2, default=str)
4142

42-
@mcp.tool()
43+
@mcp.tool(annotations=READONLY)
4344
@require_permission(PermissionLevel.READ)
4445
async def get_user(user_id: str) -> str:
4546
"""Get detailed information about a specific Nextcloud user.
@@ -56,7 +57,7 @@ async def get_user(user_id: str) -> str:
5657

5758

5859
def _register_write_tools(mcp: FastMCP) -> None:
59-
@mcp.tool()
60+
@mcp.tool(annotations=ADDITIVE)
6061
@require_permission(PermissionLevel.WRITE)
6162
async def create_user(user_id: str, password: str, display_name: str = "", email: str = "") -> str:
6263
"""Create a new Nextcloud user. Requires admin privileges.
@@ -81,7 +82,7 @@ async def create_user(user_id: str, password: str, display_name: str = "", email
8182

8283

8384
def _register_destructive_tools(mcp: FastMCP) -> None:
84-
@mcp.tool()
85+
@mcp.tool(annotations=DESTRUCTIVE)
8586
@require_permission(PermissionLevel.DESTRUCTIVE)
8687
async def delete_user(user_id: str) -> str:
8788
"""Permanently delete a Nextcloud user. Requires admin privileges.

tests/integration/test_server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ async def test_every_tool_has_description(self, nc_config: Config) -> None:
6666
assert tool.description, f"Tool '{tool.name}' has no description"
6767
assert len(tool.description) > 20, f"Tool '{tool.name}' description too short"
6868

69+
@pytest.mark.asyncio
70+
async def test_every_tool_has_annotations(self, nc_config: Config) -> None:
71+
mcp = create_server(nc_config)
72+
for tool in mcp._tool_manager.list_tools():
73+
assert tool.annotations is not None, f"Tool '{tool.name}' has no annotations"
74+
assert tool.annotations.readOnlyHint is not None, f"Tool '{tool.name}' missing readOnlyHint"
75+
6976
@pytest.mark.asyncio
7077
async def test_server_name(self, nc_config: Config) -> None:
7178
mcp = create_server(nc_config)

0 commit comments

Comments
 (0)