Skip to content

Commit 0725a60

Browse files
authored
Add limit/offset pagination to unbounded list tools (#35)
1 parent 957b18a commit 0725a60

23 files changed

Lines changed: 1041 additions & 188 deletions

.github/workflows/tests-integration.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ jobs:
8181
echo "::add-mask::$APP_PASS"
8282
echo "NC_APP_PASSWORD=$APP_PASS" >> "$GITHUB_ENV"
8383
84+
- name: Seed pagination test data
85+
run: python scripts/seed_pagination_data.py http://localhost:8080 admin "$NC_APP_PASSWORD"
86+
8487
- name: Run integration tests
8588
env:
8689
NEXTCLOUD_URL: http://localhost:8080

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ lint.extend-ignore = [
9393
"SIM117",
9494
"TRY003",
9595
]
96+
lint.extend-per-file-ignores."scripts/**/*.py" = [ "S314", "T201" ]
9697
lint.extend-per-file-ignores."src/nc_mcp_server/client.py" = [ "S314" ]
9798
lint.extend-per-file-ignores."src/nc_mcp_server/config.py" = [ "S104" ]
9899
lint.extend-per-file-ignores."tests/**/*.py" = [ "E402", "S", "UP" ]
@@ -105,6 +106,7 @@ pythonVersion = "3.12"
105106
typeCheckingMode = "strict"
106107
venvPath = "."
107108
venv = "venv"
109+
exclude = [ "scripts" ]
108110
reportUnusedFunction = false
109111
reportPrivateUsage = false
110112
executionEnvironments = [

scripts/seed_pagination_data.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""Seed synthetic data for pagination integration tests.
3+
4+
Creates bulk test data across multiple Nextcloud apps to verify
5+
pagination behavior with limit/offset parameters.
6+
7+
Usage: python scripts/seed_pagination_data.py <NC_URL> <USER> <PASSWORD>
8+
9+
Seeded data uses the "mcp-pagtest" prefix and lives outside the
10+
regular test cleanup path (mcp-test-suite), so it persists across
11+
individual test runs but is ephemeral in CI (container destroyed).
12+
"""
13+
14+
import sys
15+
import xml.etree.ElementTree as ET
16+
17+
import niquests
18+
19+
COUNT = 55
20+
PREFIX = "mcp-pagtest"
21+
PAGINATION_DIR = "mcp-pagination-data"
22+
23+
24+
def _ocs_data(resp: niquests.Response) -> object:
25+
"""Extract data from an OCS JSON response."""
26+
return resp.json()["ocs"]["data"]
27+
28+
29+
def seed_files(s: niquests.Session, url: str, user: str) -> None:
30+
"""Create files in a dedicated pagination test directory."""
31+
dav = f"{url}/remote.php/dav/files/{user}"
32+
s.request("MKCOL", f"{dav}/{PAGINATION_DIR}/")
33+
for i in range(1, COUNT + 1):
34+
s.put(
35+
f"{dav}/{PAGINATION_DIR}/pagtest-{i:03d}.txt",
36+
data=f"Pagination test file {i:03d}",
37+
headers={"Content-Type": "text/plain"},
38+
)
39+
print(f" {COUNT} files in {PAGINATION_DIR}/")
40+
41+
42+
def seed_conversations(s: niquests.Session, url: str) -> None:
43+
"""Create Talk group conversations."""
44+
api = f"{url}/ocs/v2.php/apps/spreed/api/v4/room"
45+
existing = {r["name"] for r in _ocs_data(s.get(api))}
46+
created = 0
47+
for i in range(1, COUNT + 1):
48+
name = f"{PREFIX}-conv-{i:03d}"
49+
if name not in existing:
50+
s.post(api, json={"roomType": 2, "roomName": name})
51+
created += 1
52+
print(f" {created} conversations (skipped {COUNT - created})")
53+
54+
55+
def seed_calendar_events(s: niquests.Session, url: str, user: str) -> None:
56+
"""Create calendar events via CalDAV PUT."""
57+
cal = f"{url}/remote.php/dav/calendars/{user}/personal"
58+
for i in range(1, COUNT + 1):
59+
uid = f"{PREFIX}-event-{i:03d}"
60+
hour = i % 24
61+
ical = (
62+
"BEGIN:VCALENDAR\r\n"
63+
"VERSION:2.0\r\n"
64+
"PRODID:-//NC MCP//Pagination Test//EN\r\n"
65+
"BEGIN:VEVENT\r\n"
66+
f"UID:{uid}\r\n"
67+
f"SUMMARY:Pagination Test Event {i:03d}\r\n"
68+
f"DTSTART:20270601T{hour:02d}0000Z\r\n"
69+
f"DTEND:20270601T{hour:02d}3000Z\r\n"
70+
f"DESCRIPTION:Seeded event {i:03d} for pagination testing\r\n"
71+
"DTSTAMP:20270101T000000Z\r\n"
72+
"END:VEVENT\r\n"
73+
"END:VCALENDAR\r\n"
74+
)
75+
s.put(f"{cal}/{uid}.ics", data=ical, headers={"Content-Type": "text/calendar; charset=utf-8"})
76+
print(f" {COUNT} calendar events")
77+
78+
79+
def seed_trash(s: niquests.Session, url: str, user: str) -> None:
80+
"""Create files then delete them to populate the trash bin."""
81+
dav = f"{url}/remote.php/dav/files/{user}"
82+
trash_dir = f"{PREFIX}-trash"
83+
s.request("MKCOL", f"{dav}/{trash_dir}/")
84+
for i in range(1, COUNT + 1):
85+
path = f"{dav}/{trash_dir}/trash-{i:03d}.txt"
86+
s.put(path, data=f"Trash item {i:03d}", headers={"Content-Type": "text/plain"})
87+
for i in range(1, COUNT + 1):
88+
s.delete(f"{dav}/{trash_dir}/trash-{i:03d}.txt")
89+
s.delete(f"{dav}/{trash_dir}/")
90+
print(f" {COUNT} items in trash")
91+
92+
93+
def seed_collective_pages(s: niquests.Session, url: str) -> None:
94+
"""Create a collective with many pages for pagination testing."""
95+
api = f"{url}/ocs/v2.php/apps/collectives/api/v1.0"
96+
coll_name = f"{PREFIX}-collective"
97+
98+
collectives = _ocs_data(s.get(f"{api}/collectives"))
99+
coll = next((c for c in collectives["collectives"] if c["name"] == coll_name), None)
100+
if not coll:
101+
resp = s.post(
102+
f"{api}/collectives",
103+
json={"name": coll_name},
104+
headers={"Content-Type": "application/json"},
105+
)
106+
coll = _ocs_data(resp)["collective"]
107+
coll_id = coll["id"]
108+
109+
pages_data = _ocs_data(s.get(f"{api}/collectives/{coll_id}/pages"))
110+
pages = pages_data["pages"]
111+
landing_id = pages[0]["id"]
112+
existing_titles = {p["title"] for p in pages}
113+
114+
created = 0
115+
for i in range(1, COUNT + 1):
116+
title = f"pagtest-page-{i:03d}"
117+
if title not in existing_titles:
118+
s.post(
119+
f"{api}/collectives/{coll_id}/pages/{landing_id}",
120+
json={"title": title},
121+
headers={"Content-Type": "application/json"},
122+
)
123+
created += 1
124+
# total = created + existing (minus landing page)
125+
print(f" {created} pages in collective '{coll_name}' (skipped {COUNT - created})")
126+
127+
128+
def seed_comments(s: niquests.Session, url: str, user: str) -> None:
129+
"""Create a dedicated file and add many comments to it."""
130+
dav = f"{url}/remote.php/dav/files/{user}"
131+
comment_file = f"{PAGINATION_DIR}/comment-target.txt"
132+
s.put(f"{dav}/{comment_file}", data="File with many comments", headers={"Content-Type": "text/plain"})
133+
134+
resp = s.request(
135+
"PROPFIND",
136+
f"{dav}/{comment_file}",
137+
data=(
138+
'<?xml version="1.0"?>'
139+
'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
140+
"<d:prop><oc:fileid/></d:prop>"
141+
"</d:propfind>"
142+
),
143+
headers={"Content-Type": "text/xml", "Depth": "0"},
144+
)
145+
root = ET.fromstring(resp.text)
146+
fileid_el = root.find(".//{http://owncloud.org/ns}fileid")
147+
if fileid_el is None or not fileid_el.text:
148+
print(" WARNING: could not resolve file ID for comments, skipping")
149+
return
150+
file_id = fileid_el.text
151+
152+
for i in range(1, COUNT + 1):
153+
s.post(
154+
f"{url}/remote.php/dav/comments/files/{file_id}",
155+
json={"actorType": "users", "verb": "comment", "message": f"Pagination test comment {i:03d}"},
156+
headers={"Content-Type": "application/json"},
157+
)
158+
print(f" {COUNT} comments on file {file_id}")
159+
160+
161+
def main() -> None:
162+
if len(sys.argv) != 4:
163+
print(f"Usage: {sys.argv[0]} <NC_URL> <USER> <PASSWORD>")
164+
sys.exit(1)
165+
166+
url = sys.argv[1].rstrip("/")
167+
user = sys.argv[2]
168+
password = sys.argv[3]
169+
170+
s = niquests.Session()
171+
s.auth = (user, password)
172+
s.headers.update({"OCS-APIRequest": "true", "Accept": "application/json"})
173+
174+
print(f"=== Seeding pagination test data ({COUNT} items per app) ===")
175+
176+
print("Files...")
177+
seed_files(s, url, user)
178+
179+
print("Talk conversations...")
180+
seed_conversations(s, url)
181+
182+
print("Calendar events...")
183+
seed_calendar_events(s, url, user)
184+
185+
print("Trash items...")
186+
seed_trash(s, url, user)
187+
188+
print("Collective pages...")
189+
seed_collective_pages(s, url)
190+
191+
print("Comments...")
192+
seed_comments(s, url, user)
193+
194+
print("=== Seed complete ===")
195+
s.close()
196+
197+
198+
if __name__ == "__main__":
199+
main()

src/nc_mcp_server/tools/calendar.py

Lines changed: 20 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,21 @@ 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+
default=str,
428+
)
414429

415430
@mcp.tool(annotations=READONLY)
416431
@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/comments.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ async def list_comments(file_id: int, limit: int = 20, offset: int = 0) -> str:
112112
113113
Returns:
114114
JSON object with "data" (list of comments) and "pagination"
115-
(count, offset, has_more).
115+
(count, offset, limit, has_more).
116116
"""
117117
limit = max(1, min(100, limit))
118118
offset = max(0, offset)
@@ -131,6 +131,7 @@ async def list_comments(file_id: int, limit: int = 20, offset: int = 0) -> str:
131131
"pagination": {
132132
"count": len(comments),
133133
"offset": offset,
134+
"limit": limit,
134135
"has_more": len(comments) == limit,
135136
},
136137
}

0 commit comments

Comments
 (0)