Skip to content

Commit 195648a

Browse files
committed
Add Files Sharing tools: list_shares, get_share, create_share, update_share, delete_share
1 parent dd8da7f commit 195648a

6 files changed

Lines changed: 550 additions & 2 deletions

File tree

PROGRESS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- [x] copy_file tool via WebDAV COPY (2026-03-25)
2525
- [x] get_file returns MCP ImageContent for images (PNG, JPEG, GIF, WebP, BMP, SVG) (2026-03-25)
2626
- [x] System Tags tools: list_tags, get_file_tags, create_tag, assign_tag, unassign_tag, delete_tag (2026-03-25)
27+
- [x] Files Sharing tools: list_shares, get_share, create_share, update_share, delete_share (2026-03-26)
2728

2829
### In Progress
2930

@@ -32,7 +33,7 @@
3233

3334
### Next Up
3435
- Announcement Center
35-
- Files Sharing, Trashbin, Versions
36+
- Files Trashbin, Versions
3637
- Improve error handling and error messages
3738

3839
## Phases
@@ -61,5 +62,6 @@
6162
| Errors || 10 |
6263
| Config || 12 |
6364
| State || 2 |
65+
| Shares | 5 | 33 |
6466
| System Tags | 6 | 22 |
65-
| **Total** | **42** | **313** |
67+
| **Total** | **47** | **349** |

src/nextcloud_mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
comments,
1212
files,
1313
notifications,
14+
shares,
1415
system_tags,
1516
talk,
1617
user_status,
@@ -47,6 +48,7 @@ def create_server(config: Config | None = None) -> FastMCP:
4748
comments.register(mcp)
4849
files.register(mcp)
4950
notifications.register(mcp)
51+
shares.register(mcp)
5052
system_tags.register(mcp)
5153
talk.register(mcp)
5254
user_status.register(mcp)

src/nextcloud_mcp/tools/shares.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""File sharing tools — list, get, create, update, and delete shares via OCS API."""
2+
3+
import json
4+
from typing import Any
5+
6+
from mcp.server.fastmcp import FastMCP
7+
8+
from ..annotations import ADDITIVE, DESTRUCTIVE, READONLY
9+
from ..permissions import PermissionLevel, require_permission
10+
from ..state import get_client
11+
12+
SHARES_API = "apps/files_sharing/api/v1/shares"
13+
14+
15+
def _format_share(share: dict[str, Any]) -> dict[str, Any]:
16+
"""Extract the most useful fields from a raw share object."""
17+
result: dict[str, Any] = {
18+
"id": share.get("id"),
19+
"share_type": share.get("share_type"),
20+
"path": share.get("path"),
21+
"item_type": share.get("item_type"),
22+
"permissions": share.get("permissions"),
23+
"uid_owner": share.get("uid_owner"),
24+
"share_with": share.get("share_with"),
25+
"share_with_displayname": share.get("share_with_displayname"),
26+
"expiration": share.get("expiration"),
27+
"note": share.get("note"),
28+
"label": share.get("label"),
29+
}
30+
if share.get("token"):
31+
result["token"] = share["token"]
32+
if share.get("url"):
33+
result["url"] = share["url"]
34+
if share.get("password"):
35+
result["has_password"] = True
36+
if share.get("hide_download"):
37+
result["hide_download"] = share["hide_download"]
38+
return result
39+
40+
41+
def _register_read_tools(mcp: FastMCP) -> None:
42+
@mcp.tool(annotations=READONLY)
43+
@require_permission(PermissionLevel.READ)
44+
async def list_shares(
45+
path: str = "",
46+
reshares: bool = False,
47+
subfiles: bool = False,
48+
) -> str:
49+
"""List file/folder shares from Nextcloud.
50+
51+
Without arguments, returns all shares owned by the current user.
52+
With a path, returns shares for that specific file or folder.
53+
54+
Args:
55+
path: Optional file/folder path to filter shares (e.g. "/Documents/report.pdf").
56+
reshares: If true, include shares by other users on the same files.
57+
subfiles: If true and path is a folder, list shares of files inside it (not the folder itself).
58+
59+
Returns:
60+
JSON list of share objects with: id, share_type, path, permissions, share_with, etc.
61+
share_type values: 0=user, 1=group, 3=public link, 4=email, 6=federated, 10=talk room.
62+
"""
63+
client = get_client()
64+
params: dict[str, str] = {}
65+
if path:
66+
params["path"] = path
67+
if reshares:
68+
params["reshares"] = "true"
69+
if subfiles:
70+
params["subfiles"] = "true"
71+
data = await client.ocs_get(SHARES_API, params=params)
72+
shares = [_format_share(s) for s in data]
73+
return json.dumps(shares, indent=2, default=str)
74+
75+
@mcp.tool(annotations=READONLY)
76+
@require_permission(PermissionLevel.READ)
77+
async def get_share(share_id: int) -> str:
78+
"""Get details of a specific share by its ID.
79+
80+
Args:
81+
share_id: The numeric share ID.
82+
83+
Returns:
84+
JSON object with share details: id, share_type, path, permissions, share_with,
85+
url (for link shares), expiration, note, label, etc.
86+
"""
87+
client = get_client()
88+
data = await client.ocs_get(f"{SHARES_API}/{share_id}")
89+
share = _format_share(data[0])
90+
return json.dumps(share, indent=2, default=str)
91+
92+
93+
def _register_create_share(mcp: FastMCP) -> None:
94+
@mcp.tool(annotations=ADDITIVE)
95+
@require_permission(PermissionLevel.WRITE)
96+
async def create_share(
97+
path: str,
98+
share_type: int,
99+
share_with: str = "",
100+
permissions: int = 0,
101+
password: str = "",
102+
expire_date: str = "",
103+
note: str = "",
104+
label: str = "",
105+
public_upload: bool = False,
106+
) -> str:
107+
"""Create a new share for a file or folder.
108+
109+
Args:
110+
path: Path to the file or folder to share (e.g. "/Documents/report.pdf").
111+
share_type: Type of share: 0=user, 1=group, 3=public link, 4=email, 6=federated, 10=talk room.
112+
share_with: Recipient — required for all types except link (3).
113+
User share (0): username. Group share (1): group name.
114+
Email share (4): email address. Federated (6): user@remote.server.
115+
Talk room (10): room token.
116+
permissions: Bitwise permission flags. 1=read, 2=update, 4=create, 8=delete, 16=share.
117+
Common values: 1 (read-only), 15 (full, no reshare), 31 (all).
118+
Default: all permissions (31) for user/group, read-only (1) for links.
119+
Note: file shares automatically strip create (4) and delete (8) flags.
120+
password: Optional password for link (3) or email (4) shares.
121+
expire_date: Optional expiration date in "YYYY-MM-DD" format.
122+
note: Optional note/message for the share recipient.
123+
label: Optional display label for link shares (max 255 chars).
124+
public_upload: Enable public upload on shared folders (link shares only).
125+
126+
Returns:
127+
JSON object with the created share details including id, url (for links), token, etc.
128+
"""
129+
client = get_client()
130+
data: dict[str, str | int] = {"path": path, "shareType": share_type}
131+
if share_with:
132+
data["shareWith"] = share_with
133+
if permissions > 0:
134+
data["permissions"] = permissions
135+
if password:
136+
data["password"] = password
137+
if expire_date:
138+
data["expireDate"] = expire_date
139+
if note:
140+
data["note"] = note
141+
if label:
142+
data["label"] = label
143+
if public_upload:
144+
data["publicUpload"] = "true"
145+
result = await client.ocs_post(SHARES_API, data=data)
146+
return json.dumps(_format_share(result), indent=2, default=str)
147+
148+
149+
def _register_update_share(mcp: FastMCP) -> None:
150+
@mcp.tool(annotations=ADDITIVE)
151+
@require_permission(PermissionLevel.WRITE)
152+
async def update_share(
153+
share_id: int,
154+
permissions: int = 0,
155+
password: str = "",
156+
expire_date: str = "",
157+
note: str = "",
158+
label: str = "",
159+
public_upload: bool = False,
160+
hide_download: bool = False,
161+
) -> str:
162+
"""Update properties of an existing share.
163+
164+
Only provided (non-default) parameters are changed. To remove a password or
165+
expiration, use delete_share and create a new one.
166+
167+
Args:
168+
share_id: The numeric share ID to update.
169+
permissions: New permission flags (1=read, 2=update, 4=create, 8=delete, 16=share).
170+
password: Set or change password (link/email shares only).
171+
expire_date: Set expiration date in "YYYY-MM-DD" format.
172+
note: Set or update the share note.
173+
label: Set or update the share label.
174+
public_upload: Enable/disable public upload on shared folders (link shares only).
175+
hide_download: Hide the download button on public link shares.
176+
177+
Returns:
178+
JSON object with the updated share details.
179+
"""
180+
client = get_client()
181+
data: dict[str, str | int] = {}
182+
if permissions > 0:
183+
data["permissions"] = permissions
184+
if password:
185+
data["password"] = password
186+
if expire_date:
187+
data["expireDate"] = expire_date
188+
if note:
189+
data["note"] = note
190+
if label:
191+
data["label"] = label
192+
if public_upload:
193+
data["publicUpload"] = "true"
194+
if hide_download:
195+
data["hideDownload"] = "true"
196+
result = await client.ocs_put(f"{SHARES_API}/{share_id}", data=data)
197+
return json.dumps(_format_share(result), indent=2, default=str)
198+
199+
200+
def _register_destructive_tools(mcp: FastMCP) -> None:
201+
@mcp.tool(annotations=DESTRUCTIVE)
202+
@require_permission(PermissionLevel.DESTRUCTIVE)
203+
async def delete_share(share_id: int) -> str:
204+
"""Delete (unshare) a share by its ID.
205+
206+
This revokes access for the share recipient. The file/folder itself is not deleted.
207+
208+
Args:
209+
share_id: The numeric share ID to delete. Use list_shares to find share IDs.
210+
211+
Returns:
212+
Confirmation message.
213+
"""
214+
client = get_client()
215+
await client.ocs_delete(f"{SHARES_API}/{share_id}")
216+
return f"Share {share_id} deleted."
217+
218+
219+
def register(mcp: FastMCP) -> None:
220+
"""Register file sharing tools with the MCP server."""
221+
_register_read_tools(mcp)
222+
_register_create_share(mcp)
223+
_register_update_share(mcp)
224+
_register_destructive_tools(mcp)

tests/integration/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ async def _clean_test_data(_cleanup_config: Config) -> AsyncGenerator[None]:
139139

140140
async def _cleanup(client: NextcloudClient) -> None:
141141
"""Remove all test artifacts from Nextcloud."""
142+
with contextlib.suppress(Exception):
143+
shares = await client.ocs_get("apps/files_sharing/api/v1/shares")
144+
for share in shares:
145+
with contextlib.suppress(Exception):
146+
await client.ocs_delete(f"apps/files_sharing/api/v1/shares/{share['id']}")
142147
with contextlib.suppress(Exception):
143148
await client.dav_delete(TEST_BASE_DIR)
144149
with contextlib.suppress(Exception):

tests/integration/test_server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
"create_conversation",
1818
"create_directory",
1919
"create_poll",
20+
"create_share",
2021
"create_tag",
2122
"create_user",
2223
"delete_comment",
2324
"delete_file",
2425
"delete_message",
26+
"delete_share",
2527
"delete_tag",
2628
"delete_user",
2729
"dismiss_all_notifications",
@@ -35,20 +37,23 @@
3537
"get_messages",
3638
"get_participants",
3739
"get_poll",
40+
"get_share",
3841
"get_user",
3942
"get_user_status",
4043
"leave_conversation",
4144
"list_comments",
4245
"list_conversations",
4346
"list_directory",
4447
"list_notifications",
48+
"list_shares",
4549
"list_tags",
4650
"list_users",
4751
"move_file",
4852
"search_files",
4953
"send_message",
5054
"set_user_status",
5155
"unassign_tag",
56+
"update_share",
5257
"upload_file",
5358
"vote_poll",
5459
]

0 commit comments

Comments
 (0)