Skip to content

Commit 203b3ad

Browse files
committed
switch pagination to single decorator with callback inspection
1 parent b7e6a0c commit 203b3ad

File tree

8 files changed

+429
-68
lines changed

8 files changed

+429
-68
lines changed

README.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,7 +1762,7 @@ server = Server("paginated-server")
17621762
ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items
17631763

17641764

1765-
@server.list_resources_paginated()
1765+
@server.list_resources()
17661766
async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult:
17671767
"""List resources with pagination support."""
17681768
page_size = 10
@@ -1786,12 +1786,6 @@ async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListRes
17861786
_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_
17871787
<!-- /snippet-source -->
17881788

1789-
Similar decorators are available for all list operations:
1790-
1791-
- `@server.list_tools_paginated()` - for paginating tools
1792-
- `@server.list_resources_paginated()` - for paginating resources
1793-
- `@server.list_prompts_paginated()` - for paginating prompts
1794-
17951789
#### Client-side Consumption
17961790

17971791
<!-- snippet-source examples/snippets/clients/pagination_client.py -->
@@ -1849,8 +1843,6 @@ _Full example: [examples/snippets/clients/pagination_client.py](https://github.c
18491843
- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page)
18501844
- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics
18511845

1852-
> **NOTE**: The paginated decorators (`list_tools_paginated()`, `list_resources_paginated()`, `list_prompts_paginated()`) are mutually exclusive with their non-paginated counterparts and cannot be used together on the same server instance.
1853-
18541846
See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation.
18551847

18561848
### Writing MCP Clients

examples/servers/simple-pagination/mcp_simple_pagination/server.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def main(port: int, transport: str) -> int:
5858
app = Server("mcp-simple-pagination")
5959

6060
# Paginated list_tools - returns 5 tools per page
61-
@app.list_tools_paginated()
61+
@app.list_tools()
6262
async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsResult:
6363
page_size = 5
6464

@@ -84,7 +84,7 @@ async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsRe
8484
return types.ListToolsResult(tools=page_tools, nextCursor=next_cursor)
8585

8686
# Paginated list_resources - returns 10 resources per page
87-
@app.list_resources_paginated()
87+
@app.list_resources()
8888
async def list_resources_paginated(
8989
cursor: types.Cursor | None,
9090
) -> types.ListResourcesResult:
@@ -112,7 +112,7 @@ async def list_resources_paginated(
112112
return types.ListResourcesResult(resources=page_resources, nextCursor=next_cursor)
113113

114114
# Paginated list_prompts - returns 7 prompts per page
115-
@app.list_prompts_paginated()
115+
@app.list_prompts()
116116
async def list_prompts_paginated(
117117
cursor: types.Cursor | None,
118118
) -> types.ListPromptsResult:

examples/snippets/servers/pagination_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items
1515

1616

17-
@server.list_resources_paginated()
17+
@server.list_resources()
1818
async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult:
1919
"""List resources with pagination support."""
2020
page_size = 10
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import inspect
2+
from collections.abc import Callable
3+
from typing import Any
4+
5+
6+
def accepts_cursor(func: Callable[..., Any]) -> bool:
7+
"""
8+
True if the function accepts a cursor parameter call, otherwise false.
9+
10+
`accepts_cursor` does not validate that the function will work. For
11+
example, if `func` contains keyword-only arguments with no defaults,
12+
then it will not work when used in the `lowlevel/server.py` code, but
13+
this function will not raise an exception.
14+
"""
15+
try:
16+
sig = inspect.signature(func)
17+
except (ValueError, TypeError):
18+
return False
19+
20+
params = dict(sig.parameters.items())
21+
22+
method = inspect.ismethod(func)
23+
24+
if method:
25+
params.pop("self", None)
26+
params.pop("cls", None)
27+
28+
if len(params) == 0:
29+
# No parameters at all - can't accept cursor
30+
return False
31+
32+
# Check if ALL remaining parameters are keyword-only
33+
all_keyword_only = all(param.kind == inspect.Parameter.KEYWORD_ONLY for param in params.values())
34+
35+
if all_keyword_only:
36+
# If all params are keyword-only, check if they ALL have defaults
37+
# If they do, the function can be called with no arguments -> no cursor
38+
all_have_defaults = all(param.default is not inspect.Parameter.empty for param in params.values())
39+
return not all_have_defaults # False if all have defaults (no cursor), True otherwise
40+
41+
# Check if the ONLY parameter is **kwargs (VAR_KEYWORD)
42+
# A function with only **kwargs can't accept a positional cursor argument
43+
if len(params) == 1:
44+
only_param = next(iter(params.values()))
45+
if only_param.kind == inspect.Parameter.VAR_KEYWORD:
46+
return False # Can't pass positional cursor to **kwargs
47+
48+
# Has at least one positional or variadic parameter - can accept cursor
49+
# Important note: this is designed to _not_ handle the situation where
50+
# there are multiple keyword only arguments with no defaults. In those
51+
# situations it's an invalid handler function, and will error. But it's
52+
# not the responsibility of this function to check the validity of a
53+
# callback.
54+
return True

src/mcp/server/lowlevel/server.py

Lines changed: 64 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ async def main():
8282
from typing_extensions import TypeVar
8383

8484
import mcp.types as types
85+
from mcp.server.lowlevel.func_inspection import accepts_cursor
8586
from mcp.server.lowlevel.helper_types import ReadResourceContents
8687
from mcp.server.models import InitializationOptions
8788
from mcp.server.session import ServerSession
@@ -229,25 +230,29 @@ def request_context(
229230
return request_ctx.get()
230231

231232
def list_prompts(self):
232-
def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]):
233+
def decorator(
234+
func: Callable[[], Awaitable[list[types.Prompt]]]
235+
| Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]],
236+
):
233237
logger.debug("Registering handler for PromptListRequest")
238+
pass_cursor = accepts_cursor(func)
234239

235-
async def handler(_: Any):
236-
prompts = await func()
237-
return types.ServerResult(types.ListPromptsResult(prompts=prompts))
240+
if pass_cursor:
241+
cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]], func)
238242

239-
self.request_handlers[types.ListPromptsRequest] = handler
240-
return func
243+
async def cursor_handler(req: types.ListPromptsRequest):
244+
result = await cursor_func(req.params.cursor if req.params is not None else None)
245+
return types.ServerResult(result)
241246

242-
return decorator
247+
handler = cursor_handler
248+
else:
249+
list_func = cast(Callable[[], Awaitable[list[types.Prompt]]], func)
243250

244-
def list_prompts_paginated(self):
245-
def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]]):
246-
logger.debug("Registering handler for PromptListRequest with pagination")
251+
async def list_handler(_: types.ListPromptsRequest):
252+
result = await list_func()
253+
return types.ServerResult(types.ListPromptsResult(prompts=result))
247254

248-
async def handler(req: types.ListPromptsRequest):
249-
result = await func(req.params.cursor if req.params else None)
250-
return types.ServerResult(result)
255+
handler = list_handler
251256

252257
self.request_handlers[types.ListPromptsRequest] = handler
253258
return func
@@ -270,25 +275,29 @@ async def handler(req: types.GetPromptRequest):
270275
return decorator
271276

272277
def list_resources(self):
273-
def decorator(func: Callable[[], Awaitable[list[types.Resource]]]):
278+
def decorator(
279+
func: Callable[[], Awaitable[list[types.Resource]]]
280+
| Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]],
281+
):
274282
logger.debug("Registering handler for ListResourcesRequest")
283+
pass_cursor = accepts_cursor(func)
275284

276-
async def handler(_: Any):
277-
resources = await func()
278-
return types.ServerResult(types.ListResourcesResult(resources=resources))
285+
if pass_cursor:
286+
cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]], func)
279287

280-
self.request_handlers[types.ListResourcesRequest] = handler
281-
return func
288+
async def cursor_handler(req: types.ListResourcesRequest):
289+
result = await cursor_func(req.params.cursor if req.params is not None else None)
290+
return types.ServerResult(result)
282291

283-
return decorator
292+
handler = cursor_handler
293+
else:
294+
list_func = cast(Callable[[], Awaitable[list[types.Resource]]], func)
284295

285-
def list_resources_paginated(self):
286-
def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]]):
287-
logger.debug("Registering handler for ListResourcesRequest with pagination")
296+
async def list_handler(_: types.ListResourcesRequest):
297+
result = await list_func()
298+
return types.ServerResult(types.ListResourcesResult(resources=result))
288299

289-
async def handler(req: types.ListResourcesRequest):
290-
result = await func(req.params.cursor if req.params else None)
291-
return types.ServerResult(result)
300+
handler = list_handler
292301

293302
self.request_handlers[types.ListResourcesRequest] = handler
294303
return func
@@ -406,33 +415,36 @@ async def handler(req: types.UnsubscribeRequest):
406415
return decorator
407416

408417
def list_tools(self):
409-
def decorator(func: Callable[[], Awaitable[list[types.Tool]]]):
418+
def decorator(
419+
func: Callable[[], Awaitable[list[types.Tool]]]
420+
| Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]],
421+
):
410422
logger.debug("Registering handler for ListToolsRequest")
411-
412-
async def handler(_: Any):
413-
tools = await func()
414-
# Refresh the tool cache
415-
self._tool_cache.clear()
416-
for tool in tools:
417-
self._tool_cache[tool.name] = tool
418-
return types.ServerResult(types.ListToolsResult(tools=tools))
419-
420-
self.request_handlers[types.ListToolsRequest] = handler
421-
return func
422-
423-
return decorator
424-
425-
def list_tools_paginated(self):
426-
def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]]):
427-
logger.debug("Registering paginated handler for ListToolsRequest")
428-
429-
async def handler(request: types.ListToolsRequest):
430-
cursor = request.params.cursor if request.params else None
431-
result = await func(cursor)
432-
# Refresh the tool cache with returned tools
433-
for tool in result.tools:
434-
self._tool_cache[tool.name] = tool
435-
return types.ServerResult(result)
423+
pass_cursor = accepts_cursor(func)
424+
425+
if pass_cursor:
426+
cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]], func)
427+
428+
async def cursor_handler(req: types.ListToolsRequest):
429+
result = await cursor_func(req.params.cursor if req.params is not None else None)
430+
# Refresh the tool cache with returned tools
431+
for tool in result.tools:
432+
self._tool_cache[tool.name] = tool
433+
return types.ServerResult(result)
434+
435+
handler = cursor_handler
436+
else:
437+
list_func = cast(Callable[[], Awaitable[list[types.Tool]]], func)
438+
439+
async def list_handler(req: types.ListToolsRequest):
440+
result = await list_func()
441+
# Clear and refresh the entire tool cache
442+
self._tool_cache.clear()
443+
for tool in result:
444+
self._tool_cache[tool.name] = tool
445+
return types.ServerResult(types.ListToolsResult(tools=result))
446+
447+
handler = list_handler
436448

437449
self.request_handlers[types.ListToolsRequest] = handler
438450
return func

0 commit comments

Comments
 (0)