Skip to content

Commit 9e7b1a0

Browse files
authored
Add pagination to list_users, list_apps, list_tags, list_versions, get_participants (#36)
1 parent 0725a60 commit 9e7b1a0

11 files changed

Lines changed: 167 additions & 103 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ reportPrivateUsage = false
112112
executionEnvironments = [
113113
{ root = "tests/test_client_retry.py", reportAttributeAccessIssue = false, reportUnknownMemberType = false },
114114
{ root = "src/nc_mcp_server/tools/calendar.py", reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnknownArgumentType = false },
115+
{ root = "src/nc_mcp_server/tools/users.py", reportUnknownVariableType = false, reportUnknownArgumentType = false },
115116
]
116117

117118
[tool.pytest]

src/nc_mcp_server/tools/app_management.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,27 +25,39 @@ def _format_app(a: dict[str, Any]) -> dict[str, Any]:
2525
def _register_read_tools(mcp: FastMCP) -> None:
2626
@mcp.tool(annotations=READONLY)
2727
@require_permission(PermissionLevel.READ)
28-
async def list_apps(app_filter: str = "enabled") -> str:
28+
async def list_apps(app_filter: str = "enabled", limit: int = 50, offset: int = 0) -> str:
2929
"""List installed Nextcloud apps. Requires admin privileges.
3030
31-
Returns the list of app IDs matching the filter criteria.
32-
3331
Args:
3432
app_filter: Filter apps by status. Options:
3533
"enabled" (default) — only enabled apps
3634
"disabled" — only disabled apps
3735
"all" — all installed apps
36+
limit: Maximum number of apps to return (1-500, default 50).
37+
offset: Number of apps to skip for pagination (default 0).
3838
3939
Returns:
40-
JSON list of app ID strings.
40+
JSON with "data" (list of app ID strings) and "pagination"
41+
(count, offset, limit, has_more).
4142
"""
4243
valid = {"enabled", "disabled", "all"}
4344
if app_filter not in valid:
4445
raise ValueError(f"Invalid filter '{app_filter}'. Must be one of: {', '.join(sorted(valid))}")
46+
limit = max(1, min(500, limit))
47+
offset = max(0, offset)
4548
client = get_client()
4649
params = {} if app_filter == "all" else {"filter": app_filter}
4750
data = await client.ocs_get(APPS_API, params=params)
48-
return json.dumps(data["apps"])
51+
all_apps = data["apps"]
52+
page = all_apps[offset : offset + limit]
53+
has_more = offset + limit < len(all_apps)
54+
return json.dumps(
55+
{
56+
"data": page,
57+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
58+
},
59+
default=str,
60+
)
4961

5062
@mcp.tool(annotations=READONLY)
5163
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/system_tags.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,22 @@ def _parse_tags_xml(xml_text: str) -> list[dict[str, Any]]:
5959
def _register_read_tools(mcp: FastMCP) -> None:
6060
@mcp.tool(annotations=READONLY)
6161
@require_permission(PermissionLevel.READ)
62-
async def list_tags() -> str:
63-
"""List all system tags available in this Nextcloud instance.
62+
async def list_tags(limit: int = 50, offset: int = 0) -> str:
63+
"""List system tags available in this Nextcloud instance.
6464
6565
System tags are shared labels that can be assigned to files for
66-
organization and filtering. Tags have visibility and assignability
67-
settings controlled by admins.
66+
organization and filtering.
67+
68+
Args:
69+
limit: Maximum number of tags to return (1-500, default 50).
70+
offset: Number of tags to skip for pagination (default 0).
6871
6972
Returns:
70-
JSON list of tags, each with: id, name, user_visible, user_assignable.
73+
JSON with "data" (list of tags with id, name, user_visible,
74+
user_assignable) and "pagination" (count, offset, limit, has_more).
7175
"""
76+
limit = max(1, min(500, limit))
77+
offset = max(0, offset)
7278
client = get_client()
7379
response = await client.dav_request(
7480
"PROPFIND",
@@ -77,8 +83,16 @@ async def list_tags() -> str:
7783
headers={"Content-Type": "application/xml; charset=utf-8"},
7884
context="List system tags",
7985
)
80-
tags = _parse_tags_xml(response.text or "")
81-
return json.dumps(tags)
86+
all_tags = _parse_tags_xml(response.text or "")
87+
page = all_tags[offset : offset + limit]
88+
has_more = offset + limit < len(all_tags)
89+
return json.dumps(
90+
{
91+
"data": page,
92+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
93+
},
94+
default=str,
95+
)
8296

8397
@mcp.tool(annotations=READONLY)
8498
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/talk.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,21 +237,33 @@ async def get_messages(
237237

238238
@mcp.tool(annotations=READONLY)
239239
@require_permission(PermissionLevel.READ)
240-
async def get_participants(token: str) -> str:
240+
async def get_participants(token: str, limit: int = 50, offset: int = 0) -> str:
241241
"""List participants in a Talk conversation.
242242
243243
Args:
244244
token: The conversation token. Use list_conversations to find tokens.
245+
limit: Maximum number of participants to return (1-200, default 50).
246+
offset: Number of participants to skip for pagination (default 0).
245247
246248
Returns:
247-
JSON list of participant objects, each with: attendee_id,
248-
actor_id, display_name, participant_type (owner/moderator/user/guest),
249-
in_call status.
249+
JSON with "data" (list of participant objects with attendee_id,
250+
actor_id, display_name, participant_type, in_call) and
251+
"pagination" (count, offset, limit, has_more).
250252
"""
253+
limit = max(1, min(200, limit))
254+
offset = max(0, offset)
251255
client = get_client()
252256
data = await client.ocs_get(f"apps/spreed/api/v4/room/{token}/participants")
253-
participants = [_format_participant(p) for p in data]
254-
return json.dumps(participants, default=str)
257+
all_participants = [_format_participant(p) for p in data]
258+
page = all_participants[offset : offset + limit]
259+
has_more = offset + limit < len(all_participants)
260+
return json.dumps(
261+
{
262+
"data": page,
263+
"pagination": {"count": len(page), "offset": offset, "limit": limit, "has_more": has_more},
264+
},
265+
default=str,
266+
)
255267

256268
@mcp.tool(annotations=READONLY)
257269
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/users.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,32 @@ async def get_current_user() -> str:
2525
@mcp.tool(annotations=READONLY)
2626
@require_permission(PermissionLevel.READ)
2727
async def list_users(search: str = "", limit: int = 25, offset: int = 0) -> str:
28-
"""List Nextcloud users.
28+
"""List Nextcloud users. Uses server-side pagination.
2929
3030
Args:
3131
search: Optional search string to filter users by name/email.
32-
limit: Maximum number of users to return (default 25).
33-
offset: Offset for pagination.
32+
limit: Maximum number of users to return (1-200, default 25).
33+
offset: Number of users to skip for pagination (default 0).
3434
3535
Returns:
36-
JSON list of user IDs matching the search.
36+
JSON with "data" (list of user ID strings) and "pagination"
37+
(count, offset, limit, has_more).
3738
"""
39+
limit = max(1, min(200, limit))
40+
offset = max(0, offset)
3841
client = get_client()
3942
params = {"search": search, "limit": str(limit), "offset": str(offset)}
4043
data = await client.ocs_get("cloud/users", params=params)
41-
return json.dumps(data, default=str)
44+
raw = data["users"] if isinstance(data, dict) and "users" in data else data
45+
users: list[str] = list(raw) if not isinstance(raw, list) else raw
46+
has_more = len(users) == limit
47+
return json.dumps(
48+
{
49+
"data": users,
50+
"pagination": {"count": len(users), "offset": offset, "limit": limit, "has_more": has_more},
51+
},
52+
default=str,
53+
)
4254

4355
@mcp.tool(annotations=READONLY)
4456
@require_permission(PermissionLevel.READ)

src/nc_mcp_server/tools/versions.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,36 @@ def _parse_versions_xml(xml_text: str, user: str, file_id: int) -> list[dict[str
6060
def _register_read_tools(mcp: FastMCP) -> None:
6161
@mcp.tool(annotations=READONLY)
6262
@require_permission(PermissionLevel.READ)
63-
async def list_versions(file_id: int) -> str:
64-
"""List all versions of a file by its file ID.
63+
async def list_versions(file_id: int, limit: int = 50, offset: int = 0) -> str:
64+
"""List versions of a file by its file ID.
6565
66-
Returns the version history including the current version.
67-
Use the file_id from list_directory or search_files results.
66+
Returns the version history. Use the file_id from list_directory
67+
or search_files results.
6868
6969
Args:
7070
file_id: The numeric Nextcloud file ID.
71+
limit: Maximum number of versions to return (1-200, default 50).
72+
offset: Number of versions to skip for pagination (default 0).
7173
7274
Returns:
73-
JSON list of versions, each with: version_id (unix timestamp),
74-
last_modified, size, content_type, author, and optionally label.
75-
Use version_id with restore_version to revert the file.
75+
JSON with "data" (list of versions with version_id, last_modified,
76+
size, content_type, author, label) and "pagination"
77+
(count, offset, limit, has_more).
7678
"""
79+
limit = max(1, min(200, limit))
80+
offset = max(0, offset)
7781
client = get_client()
7882
xml_text = await client.versions_propfind(file_id)
79-
entries = _parse_versions_xml(xml_text, get_config().user, file_id)
80-
return json.dumps(entries, default=str)
83+
all_versions = _parse_versions_xml(xml_text, get_config().user, file_id)
84+
page = all_versions[offset : offset + limit]
85+
has_more = offset + limit < len(all_versions)
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+
)
8193

8294

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

tests/integration/test_app_management.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,28 @@
1515
class TestListApps:
1616
@pytest.mark.asyncio
1717
async def test_list_enabled_returns_list(self, nc_mcp: McpTestHelper) -> None:
18-
result = await nc_mcp.call("list_apps")
19-
apps: list[str] = json.loads(result)
18+
result = await nc_mcp.call("list_apps", limit=500)
19+
apps: list[str] = json.loads(result)["data"]
2020
assert isinstance(apps, list)
2121
assert len(apps) > 0
2222

2323
@pytest.mark.asyncio
2424
async def test_list_enabled_contains_core_apps(self, nc_mcp: McpTestHelper) -> None:
25-
result = await nc_mcp.call("list_apps", app_filter="enabled")
26-
apps = json.loads(result)
25+
result = await nc_mcp.call("list_apps", app_filter="enabled", limit=500)
26+
apps = json.loads(result)["data"]
2727
assert "files" in apps
2828
assert "dav" in apps
2929

3030
@pytest.mark.asyncio
3131
async def test_list_all(self, nc_mcp: McpTestHelper) -> None:
32-
result = await nc_mcp.call("list_apps", app_filter="all")
33-
apps = json.loads(result)
32+
result = await nc_mcp.call("list_apps", app_filter="all", limit=500)
33+
apps = json.loads(result)["data"]
3434
assert len(apps) > 0
3535

3636
@pytest.mark.asyncio
3737
async def test_list_disabled(self, nc_mcp: McpTestHelper) -> None:
38-
result = await nc_mcp.call("list_apps", app_filter="disabled")
39-
apps = json.loads(result)
38+
result = await nc_mcp.call("list_apps", app_filter="disabled", limit=500)
39+
apps = json.loads(result)["data"]
4040
assert isinstance(apps, list)
4141

4242
@pytest.mark.asyncio
@@ -73,10 +73,10 @@ class TestEnableDisableApp:
7373
async def test_disable_and_enable(self, nc_mcp: McpTestHelper) -> None:
7474
await nc_mcp.call("disable_app", app_id=SAFE_APP)
7575
try:
76-
disabled = json.loads(await nc_mcp.call("list_apps", app_filter="disabled"))
76+
disabled = json.loads(await nc_mcp.call("list_apps", app_filter="disabled", limit=500))["data"]
7777
assert SAFE_APP in disabled
7878
await nc_mcp.call("enable_app", app_id=SAFE_APP)
79-
enabled = json.loads(await nc_mcp.call("list_apps", app_filter="enabled"))
79+
enabled = json.loads(await nc_mcp.call("list_apps", app_filter="enabled", limit=500))["data"]
8080
assert SAFE_APP in enabled
8181
finally:
8282
await nc_mcp.call("enable_app", app_id=SAFE_APP)
@@ -98,8 +98,8 @@ async def test_disable_returns_confirmation(self, nc_mcp: McpTestHelper) -> None
9898
class TestAppManagementPermissions:
9999
@pytest.mark.asyncio
100100
async def test_read_only_allows_list(self, nc_mcp_read_only: McpTestHelper) -> None:
101-
result = await nc_mcp_read_only.call("list_apps")
102-
assert isinstance(json.loads(result), list)
101+
result = await nc_mcp_read_only.call("list_apps", limit=500)
102+
assert isinstance(json.loads(result)["data"], list)
103103

104104
@pytest.mark.asyncio
105105
async def test_read_only_blocks_enable(self, nc_mcp_read_only: McpTestHelper) -> None:

tests/integration/test_system_tags.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,16 @@ async def _get_test_file_id(nc_mcp: McpTestHelper) -> int:
2929
class TestListTags:
3030
@pytest.mark.asyncio
3131
async def test_returns_list(self, nc_mcp: McpTestHelper) -> None:
32-
result = await nc_mcp.call("list_tags")
33-
data = json.loads(result)
32+
result = await nc_mcp.call("list_tags", limit=200)
33+
data = json.loads(result)["data"]
3434
assert isinstance(data, list)
3535

3636
@pytest.mark.asyncio
3737
async def test_created_tag_appears_in_list(self, nc_mcp: McpTestHelper) -> None:
3838
tag_id = await _create_tag(nc_mcp, "mcp-test-visible")
3939
try:
40-
result = await nc_mcp.call("list_tags")
41-
tags = json.loads(result)
40+
result = await nc_mcp.call("list_tags", limit=200)
41+
tags = json.loads(result)["data"]
4242
ids = [t["id"] for t in tags]
4343
assert tag_id in ids
4444
tag = next(t for t in tags if t["id"] == tag_id)
@@ -53,8 +53,8 @@ async def test_created_tag_appears_in_list(self, nc_mcp: McpTestHelper) -> None:
5353
async def test_tag_has_required_fields(self, nc_mcp: McpTestHelper) -> None:
5454
tag_id = await _create_tag(nc_mcp, "mcp-test-fields")
5555
try:
56-
result = await nc_mcp.call("list_tags")
57-
tags = json.loads(result)
56+
result = await nc_mcp.call("list_tags", limit=200)
57+
tags = json.loads(result)["data"]
5858
tag = next(t for t in tags if t["id"] == tag_id)
5959
for field in ["id", "name", "user_visible", "user_assignable"]:
6060
assert field in tag, f"Missing field: {field}"
@@ -100,7 +100,7 @@ async def test_create_non_assignable_tag(self, nc_mcp: McpTestHelper) -> None:
100100
result = await nc_mcp.call("create_tag", name="mcp-test-noa", user_assignable=False)
101101
tag_id = int(json.loads(result)["id"])
102102
try:
103-
tags = json.loads(await nc_mcp.call("list_tags"))
103+
tags = json.loads(await nc_mcp.call("list_tags", limit=200))["data"]
104104
tag = next(t for t in tags if t["id"] == tag_id)
105105
assert tag["user_assignable"] is False
106106
finally:
@@ -218,7 +218,7 @@ async def test_delete_tag(self, nc_mcp: McpTestHelper) -> None:
218218
tag_id = await _create_tag(nc_mcp, "mcp-test-delete")
219219
result = await nc_mcp.call("delete_tag", tag_id=tag_id)
220220
assert "deleted" in result.lower()
221-
tags = json.loads(await nc_mcp.call("list_tags"))
221+
tags = json.loads(await nc_mcp.call("list_tags", limit=200))["data"]
222222
assert not any(t["id"] == tag_id for t in tags)
223223

224224
@pytest.mark.asyncio
@@ -239,8 +239,8 @@ async def test_delete_removes_from_files(self, nc_mcp: McpTestHelper) -> None:
239239
class TestSystemTagPermissions:
240240
@pytest.mark.asyncio
241241
async def test_read_only_allows_list(self, nc_mcp_read_only: McpTestHelper) -> None:
242-
result = await nc_mcp_read_only.call("list_tags")
243-
assert isinstance(json.loads(result), list)
242+
result = await nc_mcp_read_only.call("list_tags", limit=200)
243+
assert isinstance(json.loads(result)["data"], list)
244244

245245
@pytest.mark.asyncio
246246
async def test_read_only_blocks_create(self, nc_mcp_read_only: McpTestHelper) -> None:

tests/integration/test_talk.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,8 @@ class TestGetParticipants:
268268
async def test_returns_participants_list(self, nc_mcp: McpTestHelper) -> None:
269269
room = await _create_room(nc_mcp, "test-participants")
270270
try:
271-
result = await nc_mcp.call("get_participants", token=str(room["token"]))
272-
data: list[Any] = json.loads(result)
271+
result = await nc_mcp.call("get_participants", token=str(room["token"]), limit=200)
272+
data: list[Any] = json.loads(result)["data"]
273273
assert isinstance(data, list)
274274
assert len(data) >= 1
275275
finally:
@@ -279,8 +279,8 @@ async def test_returns_participants_list(self, nc_mcp: McpTestHelper) -> None:
279279
async def test_creator_is_owner(self, nc_mcp: McpTestHelper) -> None:
280280
room = await _create_room(nc_mcp, "test-owner")
281281
try:
282-
result = await nc_mcp.call("get_participants", token=str(room["token"]))
283-
data = json.loads(result)
282+
result = await nc_mcp.call("get_participants", token=str(room["token"]), limit=200)
283+
data = json.loads(result)["data"]
284284
admin = next(p for p in data if p["actor_id"] == "admin")
285285
assert admin["participant_type"] == "owner"
286286
finally:
@@ -290,8 +290,8 @@ async def test_creator_is_owner(self, nc_mcp: McpTestHelper) -> None:
290290
async def test_participant_has_required_fields(self, nc_mcp: McpTestHelper) -> None:
291291
room = await _create_room(nc_mcp, "test-part-fields")
292292
try:
293-
result = await nc_mcp.call("get_participants", token=str(room["token"]))
294-
data = json.loads(result)
293+
result = await nc_mcp.call("get_participants", token=str(room["token"]), limit=200)
294+
data = json.loads(result)["data"]
295295
p = data[0]
296296
required = ["attendee_id", "actor_type", "actor_id", "display_name", "participant_type", "in_call"]
297297
for field in required:
@@ -437,8 +437,8 @@ async def test_creator_is_participant(self, nc_mcp: McpTestHelper) -> None:
437437
result = await nc_mcp.call("create_conversation", room_type=2, name="test-creator-part")
438438
data = json.loads(result)
439439
try:
440-
participants = json.loads(await nc_mcp.call("get_participants", token=str(data["token"])))
441-
actor_ids = [p["actor_id"] for p in participants]
440+
raw = await nc_mcp.call("get_participants", token=str(data["token"]), limit=200)
441+
actor_ids = [p["actor_id"] for p in json.loads(raw)["data"]]
442442
assert "admin" in actor_ids
443443
finally:
444444
await _delete_room(nc_mcp, str(data["token"]))

0 commit comments

Comments
 (0)