Skip to content

Commit 38706c1

Browse files
author
vijay
committed
Seed the pagination cursor and preserve request _meta across skills pages
`list_skills`/`read_directory` now seed the seen-cursor set with a caller-supplied starting cursor, so a server echoing that cursor is caught on the first page instead of being chased a second time. Rebuilding each page request from the caller's own params (via model_copy) also carries `_meta` forward to every page rather than dropping it after the first.
1 parent 70493de commit 38706c1

2 files changed

Lines changed: 53 additions & 6 deletions

File tree

‎src/mcp/client/skills.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,14 @@ async def list_skills(session: ClientSession, params: ListSkillsParams | None =
5757
its response is not SEP-2640 conformant.
5858
"""
5959
_require_extension(session)
60-
cursor = params.cursor if params is not None else None
60+
base = params if params is not None else ListSkillsParams()
61+
cursor = base.cursor
6162
skills: list[Skill] = []
62-
seen_cursors: set[str] = set()
63+
seen_cursors: set[str] = {cursor} if cursor is not None else set()
6364
while True:
64-
page = await session.send_request(ListSkillsRequest(params=ListSkillsParams(cursor=cursor)), ListSkillsResult)
65+
page = await session.send_request(
66+
ListSkillsRequest(params=base.model_copy(update={"cursor": cursor})), ListSkillsResult
67+
)
6568
validate_list_result(page)
6669
skills.extend(page.skills)
6770
if page.next_cursor is None:
@@ -109,12 +112,13 @@ async def read_directory(session: ClientSession, uri: str, params: ReadDirectory
109112
setting, or its response is not a valid child listing of `uri`.
110113
"""
111114
_require_extension(session, directory_read=True)
112-
cursor = params.cursor if params is not None else None
115+
base = params if params is not None else ReadDirectoryParams(uri=uri)
116+
cursor = base.cursor
113117
resources: list[Resource] = []
114-
seen_cursors: set[str] = set()
118+
seen_cursors: set[str] = {cursor} if cursor is not None else set()
115119
while True:
116120
page = await session.send_request(
117-
ReadDirectoryRequest(params=ReadDirectoryParams(uri=uri, cursor=cursor)), ReadDirectoryResult
121+
ReadDirectoryRequest(params=base.model_copy(update={"uri": uri, "cursor": cursor})), ReadDirectoryResult
118122
)
119123
validate_directory_result(uri, page)
120124
resources.extend(page.resources)

‎tests/client/test_skills.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,46 @@ async def test_list_skills_starts_from_a_caller_supplied_cursor() -> None:
253253
async with Client(_server()) as client:
254254
skills = await list_skills(client.session, ListSkillsParams(cursor="page-2"))
255255
assert [s.uri for s in skills] == ["skill://other/SKILL.md"]
256+
257+
258+
async def test_list_skills_detects_a_server_repeating_the_caller_supplied_cursor() -> None:
259+
"""The starting cursor seeds the seen-cursor set: a server that hands back the very cursor
260+
the caller resumed from is caught as a repeat on the first page, not chased a second time."""
261+
calls = 0
262+
263+
async def handler(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
264+
nonlocal calls
265+
calls += 1
266+
return ListSkillsResult(skills=[_skill()], next_cursor="resume-here")
267+
268+
server = MCPServer("catalog", extensions=[Skills(list_skills=handler, get_skill=_get_skill)])
269+
async with Client(server) as client:
270+
with pytest.raises(ValueError, match="repeated"):
271+
await list_skills(client.session, ListSkillsParams(cursor="resume-here"))
272+
assert calls == 1
273+
274+
275+
async def test_list_skills_threads_request_meta_onto_every_page() -> None:
276+
"""A caller-supplied `_meta` rides along with each page request, not just the first."""
277+
seen_meta: list[Any] = []
278+
279+
async def handler(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
280+
seen_meta.append(params.meta)
281+
if params.cursor is None:
282+
return ListSkillsResult(skills=[_skill()], next_cursor="page-2")
283+
return ListSkillsResult(
284+
skills=[
285+
Skill(
286+
uri="skill://other/SKILL.md", frontmatter={"name": "other", "description": "d"}, resources="dynamic"
287+
)
288+
],
289+
next_cursor=None,
290+
)
291+
292+
server = MCPServer("catalog", extensions=[Skills(list_skills=handler, get_skill=_get_skill)])
293+
async with Client(server) as client:
294+
await list_skills(client.session, ListSkillsParams(meta={"progressToken": "t"}))
295+
# The transport enriches `_meta` with its own keys; what matters is the caller's token
296+
# reaching the server on both the first page and the cursor-following second one.
297+
assert len(seen_meta) == 2
298+
assert all(m is not None and m.get("progress_token") == "t" for m in seen_meta)

0 commit comments

Comments
 (0)