Skip to content

Commit 787f037

Browse files
authored
feat: add pagination for secrets (#179)
1 parent 4d23fb4 commit 787f037

7 files changed

Lines changed: 214 additions & 50 deletions

File tree

src/deepset_mcp/api/secrets/models.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,3 @@ class Secret(BaseModel):
1010

1111
name: str
1212
secret_id: str
13-
14-
15-
class SecretList(BaseModel):
16-
"""Model representing a list of secrets with pagination."""
17-
18-
data: list[Secret]
19-
has_more: bool
20-
total: int

src/deepset_mcp/api/secrets/protocols.py

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

55
from typing import Protocol
66

7-
from deepset_mcp.api.secrets.models import Secret, SecretList
8-
from deepset_mcp.api.shared_models import NoContentResponse
7+
from deepset_mcp.api.secrets.models import Secret
8+
from deepset_mcp.api.shared_models import NoContentResponse, PaginatedResponse
99

1010

1111
class SecretResourceProtocol(Protocol):
@@ -16,7 +16,8 @@ async def list(
1616
limit: int = 10,
1717
field: str = "created_at",
1818
order: str = "DESC",
19-
) -> SecretList:
19+
after: str | None = None,
20+
) -> PaginatedResponse[Secret]:
2021
"""List secrets with pagination."""
2122
...
2223

src/deepset_mcp/api/secrets/resource.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from deepset_mcp.api.exceptions import ResourceNotFoundError
88
from deepset_mcp.api.protocols import AsyncClientProtocol
9-
from deepset_mcp.api.secrets.models import Secret, SecretList
9+
from deepset_mcp.api.secrets.models import Secret
1010
from deepset_mcp.api.secrets.protocols import SecretResourceProtocol
11-
from deepset_mcp.api.shared_models import NoContentResponse
11+
from deepset_mcp.api.shared_models import NoContentResponse, PaginatedResponse
1212
from deepset_mcp.api.transport import raise_for_status
1313

1414

@@ -27,34 +27,59 @@ async def list(
2727
limit: int = 10,
2828
field: str = "created_at",
2929
order: str = "DESC",
30-
) -> SecretList:
30+
after: str | None = None,
31+
) -> PaginatedResponse[Secret]:
3132
"""List secrets with pagination.
3233
34+
The returned object can be iterated over to fetch subsequent pages.
35+
3336
:param limit: Maximum number of secrets to return.
3437
:param field: Field to sort by.
3538
:param order: Sort order (ASC or DESC).
39+
:param after: The cursor to fetch the next page of results.
3640
37-
:returns: List of secrets with pagination info.
41+
:returns: A `PaginatedResponse` object containing the first page of secrets.
3842
"""
39-
params = {
43+
# 1. Prepare arguments for the initial API call
44+
# TODO: Pagination in the deepset API is currently implemented in an unintuitive way.
45+
# TODO: The cursor is always time based (created_at) and after signifies secrets older than the current cursor
46+
# TODO: while 'before' signals secrets younger than the current cursor.
47+
# TODO: This is applied irrespective of any sort (e.g. name) that would conflict with this approach.
48+
# TODO: Change this to 'after' once the behaviour is fixed on the deepset API
49+
request_params = {
4050
"limit": str(limit),
4151
"field": field,
4252
"order": order,
53+
"before": after,
4354
}
55+
request_params = {k: v for k, v in request_params.items() if v is not None}
56+
57+
# 2. Make the first API call using a private, stateless method
58+
page = await self._list_api_call(**request_params)
59+
60+
# 3. Inject the logic needed for subsequent fetches into the response object
61+
page._inject_paginator(
62+
fetch_func=self._list_api_call,
63+
# Base args for the *next* fetch don't include initial cursors
64+
base_args={"limit": str(limit), "field": field, "order": order},
65+
)
66+
return page
4467

68+
async def _list_api_call(self, **kwargs: Any) -> PaginatedResponse[Secret]:
69+
"""A private, stateless method that performs the raw API call."""
4570
resp = await self._client.request(
4671
endpoint="v2/secrets",
4772
method="GET",
4873
response_type=dict[str, Any],
49-
params=params,
74+
params=kwargs,
5075
)
5176

5277
raise_for_status(resp)
5378

5479
if resp.json is None:
5580
raise ResourceNotFoundError("Failed to retrieve secrets.")
5681

57-
return SecretList(**resp.json)
82+
return PaginatedResponse[Secret].create_with_cursor_field(resp.json, "secret_id")
5883

5984
async def create(self, name: str, secret: str) -> NoContentResponse:
6085
"""Create a new secret.

src/deepset_mcp/tools/secrets.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,39 +23,48 @@ class EnvironmentSecretList(BaseModel):
2323
data: list[EnvironmentSecret]
2424
has_more: bool
2525
total: int
26+
next_cursor: str | None = None
2627

2728

28-
async def list_secrets(*, client: AsyncClientProtocol, limit: int = 10) -> EnvironmentSecretList | str:
29+
async def list_secrets(
30+
*, client: AsyncClientProtocol, limit: int = 10, after: str | None = None
31+
) -> EnvironmentSecretList | str:
2932
"""Lists all secrets available in the user's deepset organization.
3033
3134
Use this tool to retrieve a list of secrets with their names and IDs.
3235
This is useful for getting an overview of all secrets before retrieving specific ones.
3336
3437
:param client: The deepset API client
3538
:param limit: Maximum number of secrets to return (default: 10)
39+
:param after: The cursor to fetch the next page of results
3640
3741
:returns: List of secrets or error message
3842
"""
3943
try:
40-
secrets_list = await client.secrets().list(limit=limit)
41-
integrations_list = await client.integrations().list()
44+
secrets_list = await client.secrets().list(limit=limit, after=after)
4245

4346
env_secrets = [EnvironmentSecret(name=secret.name, id=secret.secret_id) for secret in secrets_list.data]
44-
for integration in integrations_list.integrations:
45-
env_vars = TOKEN_DOMAIN_MAPPING.get(integration.provider_domain, [])
46-
for env_var in env_vars:
47-
env_secrets.append(
48-
EnvironmentSecret(
49-
name=env_var,
50-
id=str(integration.model_registry_token_id),
51-
invalid=integration.invalid,
47+
48+
# Only fetch integrations if no cursor is provided (first page)
49+
# This optimizes performance by skipping the integrations call for subsequent pages
50+
if after is None:
51+
integrations_list = await client.integrations().list()
52+
for integration in integrations_list.integrations:
53+
env_vars = TOKEN_DOMAIN_MAPPING.get(integration.provider_domain, [])
54+
for env_var in env_vars:
55+
env_secrets.append(
56+
EnvironmentSecret(
57+
name=env_var,
58+
id=str(integration.model_registry_token_id),
59+
invalid=integration.invalid,
60+
)
5261
)
53-
)
5462

5563
return EnvironmentSecretList(
5664
data=env_secrets,
5765
has_more=secrets_list.has_more,
5866
total=len(env_secrets),
67+
next_cursor=secrets_list.next_cursor,
5968
)
6069
except ResourceNotFoundError as e:
6170
return f"Error: {str(e)}"

test/integration/test_integration_secret_resource.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from deepset_mcp.api.client import AsyncDeepsetClient
88
from deepset_mcp.api.exceptions import ResourceNotFoundError, UnexpectedAPIError
9-
from deepset_mcp.api.secrets.models import Secret, SecretList
9+
from deepset_mcp.api.secrets.models import Secret
1010
from deepset_mcp.api.secrets.resource import SecretResource
11-
from deepset_mcp.api.shared_models import NoContentResponse
11+
from deepset_mcp.api.shared_models import NoContentResponse, PaginatedResponse
1212

1313
pytestmark = pytest.mark.integration
1414

@@ -47,7 +47,7 @@ async def test_create_and_get_secret(
4747
assert create_result.success is True
4848

4949
# List secrets to find our created secret
50-
secrets: SecretList = await secret_resource.list()
50+
secrets: PaginatedResponse[Secret] = await secret_resource.list()
5151

5252
# Find our secret in the list
5353
created_secret = None
@@ -96,7 +96,7 @@ async def test_list_secrets(secret_resource: SecretResource) -> None:
9696
break
9797

9898
# Test listing all secrets
99-
all_secrets: SecretList = await secret_resource.list()
99+
all_secrets: PaginatedResponse[Secret] = await secret_resource.list()
100100
assert len(all_secrets.data) >= 3
101101

102102
# Verify our created secrets are in the list
@@ -105,12 +105,12 @@ async def test_list_secrets(secret_resource: SecretResource) -> None:
105105
assert name in secret_names
106106

107107
# Test listing with limit
108-
limited_secrets: SecretList = await secret_resource.list(limit=2)
108+
limited_secrets: PaginatedResponse[Secret] = await secret_resource.list(limit=2)
109109
assert len(limited_secrets.data) <= 2
110110

111111
# Test with different sort order
112-
asc_secrets: SecretList = await secret_resource.list(order="ASC")
113-
desc_secrets: SecretList = await secret_resource.list(order="DESC")
112+
asc_secrets: PaginatedResponse[Secret] = await secret_resource.list(order="ASC")
113+
desc_secrets: PaginatedResponse[Secret] = await secret_resource.list(order="DESC")
114114

115115
# At least verify we got results (can't easily test order without knowing full dataset)
116116
assert len(asc_secrets.data) >= 0
@@ -139,7 +139,7 @@ async def test_delete_secret(
139139
await secret_resource.create(name=test_secret_name, secret=test_secret_value)
140140

141141
# Find the created secret
142-
secrets: SecretList = await secret_resource.list()
142+
secrets: PaginatedResponse[Secret] = await secret_resource.list()
143143
secret_to_delete = None
144144
for secret in secrets.data:
145145
if secret.name == test_secret_name:
@@ -196,3 +196,59 @@ async def test_delete_nonexistent_secret(secret_resource: SecretResource) -> Non
196196
# This is acceptable behavior for DELETE operations
197197
# API might return 404 or other error codes for nonexistent resources
198198
pass
199+
200+
201+
@pytest.mark.asyncio
202+
async def test_pagination_iteration(
203+
secret_resource: SecretResource,
204+
) -> None:
205+
"""Test iterating over multiple pages of secrets using the async iterator."""
206+
# Create several test secrets
207+
test_secrets = [
208+
("test-pagination-secret-1", "value-1"),
209+
("test-pagination-secret-2", "value-2"),
210+
("test-pagination-secret-3", "value-3"),
211+
("test-pagination-secret-4", "value-4"),
212+
("test-pagination-secret-5", "value-5"),
213+
]
214+
215+
created_secret_ids = []
216+
217+
try:
218+
# Create test secrets
219+
for name, value in test_secrets:
220+
await secret_resource.create(name=name, secret=value)
221+
# Find the created secret ID for cleanup
222+
secrets = await secret_resource.list()
223+
for secret in secrets.data:
224+
if secret.name == name and secret.secret_id not in created_secret_ids:
225+
created_secret_ids.append(secret.secret_id)
226+
break
227+
228+
# Get the first page with a small limit to ensure pagination
229+
paginator = await secret_resource.list(limit=2)
230+
231+
# Collect all secrets by iterating through pages
232+
all_secrets = []
233+
async for secret in paginator:
234+
all_secrets.append(secret)
235+
236+
# Verify we got at least our created secrets (might have more from other tests)
237+
assert len(all_secrets) >= 5
238+
239+
# Verify all secrets are Secret instances
240+
for secret in all_secrets:
241+
assert isinstance(secret, Secret)
242+
243+
# Verify our created secrets are in the results
244+
retrieved_names = [s.name for s in all_secrets]
245+
for name, _ in test_secrets:
246+
assert name in retrieved_names
247+
248+
finally:
249+
# Clean up created secrets
250+
for secret_id in created_secret_ids:
251+
try:
252+
await secret_resource.delete(secret_id)
253+
except Exception as e:
254+
print(f"Failed to delete test secret {secret_id}: {e}")

0 commit comments

Comments
 (0)