Skip to content

Commit 7f64ecd

Browse files
committed
Add limit/offset pagination to unbounded list tools
Tools updated: list_directory, list_conversations, get_events, list_shares, list_notifications, list_trash, list_collectives, get_collective_pages. All now accept limit/offset params and return {"data": [...], "pagination": {count, offset, limit, has_more}} matching the existing pattern used by search_files and get_activity. Default limit is 50 (prevents context overflow on busy instances). Client-side slicing since most Nextcloud APIs don't support server-side pagination for these endpoints.
1 parent 957b18a commit 7f64ecd

18 files changed

Lines changed: 240 additions & 129 deletions

src/nc_mcp_server/tools/calendar.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ async def get_events(
366366
calendar_id: str = "personal",
367367
start: str = "",
368368
end: str = "",
369+
limit: int = 50,
370+
offset: int = 0,
369371
) -> str:
370372
"""Get events from a calendar, optionally filtered by time range.
371373
@@ -379,13 +381,17 @@ async def get_events(
379381
Required if end is provided.
380382
end: Optional range end in ISO 8601 UTC format: "2026-04-30T23:59:59Z".
381383
Required if start is provided.
384+
limit: Maximum number of events to return (1-500, default 50).
385+
offset: Number of events to skip for pagination (default 0).
382386
383387
Returns:
384-
JSON list of event objects with: uid, summary, dtstart, dtend, location,
385-
description, status, all_day, and optionally rrule and categories.
388+
JSON with "data" (list of event objects) and "pagination"
389+
(count, offset, limit, has_more).
386390
"""
387391
if bool(start) != bool(end):
388392
raise ValueError("Both start and end are required for time-range filtering, or omit both.")
393+
limit = max(1, min(500, limit))
394+
offset = max(0, offset)
389395
caldav_start = start.replace("-", "").replace(":", "").replace(".", "") if start else None
390396
caldav_end = end.replace("-", "").replace(":", "").replace(".", "") if end else None
391397
if caldav_start:
@@ -405,12 +411,20 @@ async def get_events(
405411
context=f"Get events from '{calendar_id}'",
406412
)
407413
results = _parse_report_xml(response.text or "")
408-
events = []
414+
all_events = []
409415
for _href, etag, ical_data in results:
410416
event = _format_event(ical_data)
411417
event["etag"] = etag
412-
events.append(event)
413-
return json.dumps(events)
418+
all_events.append(event)
419+
page = all_events[offset : offset + limit]
420+
has_more = offset + limit < len(all_events)
421+
422+
return json.dumps(
423+
{
424+
"data": page,
425+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
426+
}
427+
)
414428

415429
@mcp.tool(annotations=READONLY)
416430
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/collectives.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,38 +42,66 @@ def _format_page(p: dict[str, Any]) -> dict[str, Any]:
4242
def _register_read_tools(mcp: FastMCP) -> None:
4343
@mcp.tool(annotations=READONLY)
4444
@require_permission(PermissionLevel.READ)
45-
async def list_collectives() -> str:
46-
"""List all collectives the current user has access to.
45+
async def list_collectives(limit: int = 50, offset: int = 0) -> str:
46+
"""List collectives the current user has access to.
4747
4848
Collectives are shared knowledge bases with wiki-style pages.
49-
Each collective has a landing page and may contain nested subpages.
49+
50+
Args:
51+
limit: Maximum number of collectives to return (1-200, default 50).
52+
offset: Number of collectives to skip for pagination (default 0).
5053
5154
Returns:
52-
JSON list of collectives with id, name, emoji, permissions.
55+
JSON with "data" (list of collectives with id, name, emoji, permissions)
56+
and "pagination" (count, offset, limit, has_more).
5357
"""
58+
limit = max(1, min(200, limit))
59+
offset = max(0, offset)
5460
client = get_client()
5561
data = await client.ocs_get(f"{API}/collectives")
56-
collectives = [_format_collective(c) for c in data["collectives"]]
57-
return json.dumps(collectives, default=str)
62+
all_collectives = [_format_collective(c) for c in data["collectives"]]
63+
page = all_collectives[offset : offset + limit]
64+
has_more = offset + limit < len(all_collectives)
65+
66+
return json.dumps(
67+
{
68+
"data": page,
69+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
70+
},
71+
default=str,
72+
)
5873

5974
@mcp.tool(annotations=READONLY)
6075
@require_permission(PermissionLevel.READ)
61-
async def get_collective_pages(collective_id: int) -> str:
62-
"""List all pages in a collective.
76+
async def get_collective_pages(collective_id: int, limit: int = 50, offset: int = 0) -> str:
77+
"""List pages in a collective.
6378
64-
Returns the full page tree including the landing page and all subpages.
65-
Each page has a title, emoji, timestamp, size, and file path.
79+
Returns the page tree including the landing page and all subpages.
6680
6781
Args:
6882
collective_id: The numeric collective ID. Use list_collectives to find IDs.
83+
limit: Maximum number of pages to return (1-200, default 50).
84+
offset: Number of pages to skip for pagination (default 0).
6985
7086
Returns:
71-
JSON list of pages with id, title, emoji, timestamp, size, file_name, file_path.
87+
JSON with "data" (list of pages with id, title, emoji, timestamp, size)
88+
and "pagination" (count, offset, limit, has_more).
7289
"""
90+
limit = max(1, min(200, limit))
91+
offset = max(0, offset)
7392
client = get_client()
7493
data = await client.ocs_get(f"{API}/collectives/{collective_id}/pages")
75-
pages = [_format_page(p) for p in data["pages"]]
76-
return json.dumps(pages, default=str)
94+
all_pages = [_format_page(p) for p in data["pages"]]
95+
page = all_pages[offset : offset + limit]
96+
has_more = offset + limit < len(all_pages)
97+
98+
return json.dumps(
99+
{
100+
"data": page,
101+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
102+
},
103+
default=str,
104+
)
77105

78106
@mcp.tool(annotations=READONLY)
79107
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/files.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,22 +61,35 @@ def _build_search_xml(user: str, query: str, path: str, limit: int, offset: int,
6161
def _register_read_tools(mcp: FastMCP) -> None:
6262
@mcp.tool(annotations=READONLY)
6363
@require_permission(PermissionLevel.READ)
64-
async def list_directory(path: str = "/") -> str:
64+
async def list_directory(path: str = "/", limit: int = 50, offset: int = 0) -> str:
6565
"""List files and folders in a Nextcloud directory.
6666
6767
Args:
6868
path: Directory path relative to user's root (default: "/" for root).
6969
Example: "Documents", "Photos/Vacation"
70+
limit: Maximum number of entries to return (1-500, default 50).
71+
offset: Number of entries to skip for pagination (default 0).
7072
7173
Returns:
72-
JSON list of entries, each with: path, is_directory, size, last_modified, content_type.
74+
JSON with "data" (list of entries with path, is_directory, size, etc.)
75+
and "pagination" (count, offset, limit, has_more).
7376
"""
77+
limit = max(1, min(500, limit))
78+
offset = max(0, offset)
7479
client = get_client()
7580
entries = await client.dav_propfind(path, depth=1)
76-
# First entry is the directory itself — skip it
7781
if entries and entries[0]["path"].rstrip("/") == path.strip("/"):
7882
entries = entries[1:]
79-
return json.dumps(entries, default=str)
83+
page = entries[offset : offset + limit]
84+
has_more = offset + limit < len(entries)
85+
86+
return json.dumps(
87+
{
88+
"data": page,
89+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
90+
},
91+
default=str,
92+
)
8093

8194
@mcp.tool(annotations=READONLY)
8295
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/notifications.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,33 @@ def register(mcp: FastMCP) -> None:
1414

1515
@mcp.tool(annotations=READONLY)
1616
@require_permission(PermissionLevel.READ)
17-
async def list_notifications() -> str:
18-
"""List all notifications for the current Nextcloud user.
17+
async def list_notifications(limit: int = 50, offset: int = 0) -> str:
18+
"""List notifications for the current Nextcloud user.
1919
20-
Returns notifications sorted by newest first. Each notification
21-
includes: notification_id, app, datetime, subject, message, link,
22-
and actions.
20+
Returns notifications sorted by newest first.
21+
22+
Args:
23+
limit: Maximum number of notifications to return (1-200, default 50).
24+
offset: Number of notifications to skip for pagination (default 0).
2325
2426
Returns:
25-
JSON list of notification objects.
27+
JSON with "data" (list of notification objects) and "pagination"
28+
(count, offset, limit, has_more).
2629
"""
30+
limit = max(1, min(200, limit))
31+
offset = max(0, offset)
2732
client = get_client()
28-
data = await client.ocs_get(
29-
"apps/notifications/api/v2/notifications",
33+
data = await client.ocs_get("apps/notifications/api/v2/notifications")
34+
page = data[offset : offset + limit]
35+
has_more = offset + limit < len(data)
36+
37+
return json.dumps(
38+
{
39+
"data": page,
40+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
41+
},
42+
default=str,
3043
)
31-
return json.dumps(data, default=str)
3244

3345
@mcp.tool(annotations=DESTRUCTIVE)
3446
@require_permission(PermissionLevel.DESTRUCTIVE)

src/nc_mcp_server/tools/shares.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ async def list_shares(
4545
path: str = "",
4646
reshares: bool = False,
4747
subfiles: bool = False,
48+
limit: int = 50,
49+
offset: int = 0,
4850
) -> str:
4951
"""List file/folder shares from Nextcloud.
5052
@@ -55,11 +57,15 @@ async def list_shares(
5557
path: Optional file/folder path to filter shares (e.g. "/Documents/report.pdf").
5658
reshares: If true, include shares by other users on the same files.
5759
subfiles: If true and path is a folder, list shares of files inside it (not the folder itself).
60+
limit: Maximum number of shares to return (1-200, default 50).
61+
offset: Number of shares to skip for pagination (default 0).
5862
5963
Returns:
60-
JSON list of share objects with: id, share_type, path, permissions, share_with, etc.
64+
JSON with "data" (list of share objects) and "pagination" (count, offset, limit, has_more).
6165
share_type values: 0=user, 1=group, 3=public link, 4=email, 6=federated, 10=talk room.
6266
"""
67+
limit = max(1, min(200, limit))
68+
offset = max(0, offset)
6369
client = get_client()
6470
params: dict[str, str] = {}
6571
if path:
@@ -69,8 +75,17 @@ async def list_shares(
6975
if subfiles:
7076
params["subfiles"] = "true"
7177
data = await client.ocs_get(SHARES_API, params=params)
72-
shares = [_format_share(s) for s in data]
73-
return json.dumps(shares, default=str)
78+
all_shares = [_format_share(s) for s in data]
79+
page = all_shares[offset : offset + limit]
80+
has_more = offset + limit < len(all_shares)
81+
82+
return json.dumps(
83+
{
84+
"data": page,
85+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
86+
},
87+
default=str,
88+
)
7489

7590
@mcp.tool(annotations=READONLY)
7691
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/talk.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,27 +128,43 @@ def _register_read_tools(mcp: FastMCP) -> None:
128128

129129
@mcp.tool(annotations=READONLY)
130130
@require_permission(PermissionLevel.READ)
131-
async def list_conversations(include_notifications_disabled: bool = False) -> str:
132-
"""List all Talk conversations the current user is part of.
131+
async def list_conversations(
132+
include_notifications_disabled: bool = False,
133+
limit: int = 50,
134+
offset: int = 0,
135+
) -> str:
136+
"""List Talk conversations the current user is part of.
133137
134138
Returns conversations sorted by last activity (newest first).
135-
Each conversation includes: token (unique ID for API calls), type,
136-
name, unread counts, and permissions.
137139
138140
Args:
139141
include_notifications_disabled: If true, also return conversations where
140142
notifications are disabled (default: false).
143+
limit: Maximum number of conversations to return (1-200, default 50).
144+
offset: Number of conversations to skip for pagination (default 0).
141145
142146
Returns:
143-
JSON list of conversation objects.
147+
JSON with "data" (list of conversation objects) and "pagination"
148+
(count, offset, limit, has_more).
144149
"""
150+
limit = max(1, min(200, limit))
151+
offset = max(0, offset)
145152
client = get_client()
146153
params: dict[str, str] = {}
147154
if not include_notifications_disabled:
148155
params["noStatusUpdate"] = "0"
149156
data = await client.ocs_get("apps/spreed/api/v4/room", params=params)
150-
conversations = [_format_conversation(room) for room in data]
151-
return json.dumps(conversations, default=str)
157+
all_convs = [_format_conversation(room) for room in data]
158+
page = all_convs[offset : offset + limit]
159+
has_more = offset + limit < len(all_convs)
160+
161+
return json.dumps(
162+
{
163+
"data": page,
164+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
165+
},
166+
default=str,
167+
)
152168

153169
@mcp.tool(annotations=READONLY)
154170
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/trashbin.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,22 +80,35 @@ def _parse_trash_xml(xml_text: str, user: str) -> list[dict[str, Any]]:
8080
def _register_read_tools(mcp: FastMCP) -> None:
8181
@mcp.tool(annotations=READONLY)
8282
@require_permission(PermissionLevel.READ)
83-
async def list_trash() -> str:
84-
"""List all items in the Nextcloud trash bin.
83+
async def list_trash(limit: int = 50, offset: int = 0) -> str:
84+
"""List items in the Nextcloud trash bin.
8585
8686
Returns files and folders that were deleted and can be restored.
87-
Each item includes its original filename, original path, deletion
88-
time, and a trash_path identifier needed for restore/delete operations.
87+
88+
Args:
89+
limit: Maximum number of items to return (1-200, default 50).
90+
offset: Number of items to skip for pagination (default 0).
8991
9092
Returns:
91-
JSON list of trashed items, each with: trash_path, original_name,
92-
original_location, deletion_time (unix), is_directory, size, file_id.
93-
Use trash_path with restore_trash_item or delete operations.
93+
JSON with "data" (list of trashed items with trash_path, original_name,
94+
original_location, deletion_time, is_directory, size, file_id) and
95+
"pagination" (count, offset, limit, has_more).
9496
"""
97+
limit = max(1, min(200, limit))
98+
offset = max(0, offset)
9599
client = get_client()
96100
xml_text = await client.trashbin_propfind()
97101
entries = _parse_trash_xml(xml_text, get_config().user)
98-
return json.dumps(entries, default=str)
102+
page = entries[offset : offset + limit]
103+
has_more = offset + limit < len(entries)
104+
105+
return json.dumps(
106+
{
107+
"data": page,
108+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
109+
},
110+
default=str,
111+
)
99112

100113

101114
def _register_write_tools(mcp: FastMCP) -> None:

0 commit comments

Comments
 (0)