diff --git a/docs/python-sdk/fastmcp-client-mixins-prompts.mdx b/docs/python-sdk/fastmcp-client-mixins-prompts.mdx
index 7e9c8e8f6f..bf5303caf0 100644
--- a/docs/python-sdk/fastmcp-client-mixins-prompts.mdx
+++ b/docs/python-sdk/fastmcp-client-mixins-prompts.mdx
@@ -58,7 +58,7 @@ large result sets incrementally), use list_prompts_mcp() with the cursor paramet
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `get_prompt_mcp`
+#### `get_prompt_mcp`
```python
get_prompt_mcp(self: Client, name: str, arguments: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> mcp.types.GetPromptResult
@@ -80,19 +80,19 @@ containing the prompt messages and any additional metadata.
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult
```
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> PromptTask
```
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult | PromptTask
diff --git a/docs/python-sdk/fastmcp-client-mixins-resources.mdx b/docs/python-sdk/fastmcp-client-mixins-resources.mdx
index ab89913e98..64aaf5e04c 100644
--- a/docs/python-sdk/fastmcp-client-mixins-resources.mdx
+++ b/docs/python-sdk/fastmcp-client-mixins-resources.mdx
@@ -58,7 +58,7 @@ large result sets incrementally), use list_resources_mcp() with the cursor param
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `list_resource_templates_mcp`
+#### `list_resource_templates_mcp`
```python
list_resource_templates_mcp(self: Client) -> mcp.types.ListResourceTemplatesResult
@@ -78,7 +78,7 @@ containing the list of resource templates and any additional metadata.
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `list_resource_templates`
+#### `list_resource_templates`
```python
list_resource_templates(self: Client) -> list[mcp.types.ResourceTemplate]
@@ -99,7 +99,7 @@ cursor parameter.
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `read_resource_mcp`
+#### `read_resource_mcp`
```python
read_resource_mcp(self: Client, uri: AnyUrl | str, meta: dict[str, Any] | None = None) -> mcp.types.ReadResourceResult
@@ -120,19 +120,19 @@ containing the resource contents and any additional metadata.
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self: Client, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
```
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self: Client, uri: AnyUrl | str) -> ResourceTask
```
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self: Client, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask
diff --git a/docs/python-sdk/fastmcp-client-mixins-tools.mdx b/docs/python-sdk/fastmcp-client-mixins-tools.mdx
index 769b267c2f..eda72f44d0 100644
--- a/docs/python-sdk/fastmcp-client-mixins-tools.mdx
+++ b/docs/python-sdk/fastmcp-client-mixins-tools.mdx
@@ -58,7 +58,7 @@ large result sets incrementally), use list_tools_mcp() with the cursor parameter
- `McpError`: If the request results in a TimeoutError | JSONRPCError
-#### `call_tool_mcp`
+#### `call_tool_mcp`
```python
call_tool_mcp(self: Client, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None) -> mcp.types.CallToolResult
@@ -88,19 +88,19 @@ containing the tool result and any additional metadata.
- `McpError`: If the tool call requests results in a TimeoutError | JSONRPCError
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult
```
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> ToolTask
```
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult | ToolTask
diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx
index a87096234b..0defded514 100644
--- a/docs/python-sdk/fastmcp-server-context.mdx
+++ b/docs/python-sdk/fastmcp-server-context.mdx
@@ -202,7 +202,7 @@ Works in both foreground (MCP progress notifications) and background
- `message`: Optional status message describing current progress
-#### `list_resources`
+#### `list_resources`
```python
list_resources(self) -> list[SDKResource]
@@ -214,7 +214,7 @@ List all available resources from the server.
- List of Resource objects available on the server
-#### `list_prompts`
+#### `list_prompts`
```python
list_prompts(self) -> list[SDKPrompt]
@@ -226,7 +226,7 @@ List all available prompts from the server.
- List of Prompt objects available on the server
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult
@@ -242,7 +242,7 @@ Get a prompt by name with optional arguments.
- The prompt result
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str | AnyUrl) -> ResourceResult
@@ -257,7 +257,7 @@ Read a resource by URI.
- ResourceResult with contents
-#### `log`
+#### `log`
```python
log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -275,7 +275,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien
- `extra`: Optional mapping for additional arguments
-#### `transport`
+#### `transport`
```python
transport(self) -> TransportType | None
@@ -287,7 +287,7 @@ Returns the transport type used to run this server: "stdio", "sse",
or "streamable-http". Returns None if called outside of a server context.
-#### `client_supports_extension`
+#### `client_supports_extension`
```python
client_supports_extension(self, extension_id: str) -> bool
@@ -312,7 +312,7 @@ Example::
return "text-only client"
-#### `client_id`
+#### `client_id`
```python
client_id(self) -> str | None
@@ -321,7 +321,7 @@ client_id(self) -> str | None
Get the client ID if available.
-#### `request_id`
+#### `request_id`
```python
request_id(self) -> str
@@ -332,7 +332,7 @@ Get the unique ID for this request.
Raises RuntimeError if MCP request context is not available.
-#### `session_id`
+#### `session_id`
```python
session_id(self) -> str
@@ -349,7 +349,7 @@ the same client session.
- for other transports.
-#### `session`
+#### `session`
```python
session(self) -> ServerSession
@@ -363,7 +363,7 @@ In background task mode: Returns the session stored at Context creation.
Raises RuntimeError if no session is available.
-#### `debug`
+#### `debug`
```python
debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -374,7 +374,7 @@ Send a `DEBUG`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `info`
+#### `info`
```python
info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -385,7 +385,7 @@ Send a `INFO`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `warning`
+#### `warning`
```python
warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -396,7 +396,7 @@ Send a `WARNING`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `error`
+#### `error`
```python
error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -407,7 +407,7 @@ Send a `ERROR`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `list_roots`
+#### `list_roots`
```python
list_roots(self) -> list[Root]
@@ -416,7 +416,7 @@ list_roots(self) -> list[Root]
List the roots available to the server, as indicated by the client.
-#### `send_notification`
+#### `send_notification`
```python
send_notification(self, notification: mcp.types.ServerNotificationType) -> None
@@ -428,7 +428,7 @@ Send a notification to the client immediately.
- `notification`: An MCP notification instance (e.g., ToolListChangedNotification())
-#### `close_sse_stream`
+#### `close_sse_stream`
```python
close_sse_stream(self) -> None
@@ -446,7 +446,7 @@ Instead of holding a connection open for minutes, you can periodically close
and let the client reconnect.
-#### `sample_step`
+#### `sample_step`
```python
sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep
@@ -489,7 +489,7 @@ regardless of this setting.
- - .text: The text content (if any)
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT]
@@ -498,7 +498,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: With result_type, returns SamplingResult[ResultT].
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str]
@@ -507,7 +507,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: Without result_type, returns SamplingResult[str].
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str]
@@ -555,43 +555,43 @@ regardless of this setting.
- - .history: All messages exchanged during sampling
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
@@ -620,7 +620,7 @@ type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
-#### `set_state`
+#### `set_state`
```python
set_state(self, key: str, value: Any) -> None
@@ -633,7 +633,7 @@ The key is automatically prefixed with the session identifier.
State expires after 1 day to prevent unbounded memory growth.
-#### `get_state`
+#### `get_state`
```python
get_state(self, key: str) -> Any
@@ -644,7 +644,7 @@ Get a value from the session-scoped state store.
Returns None if the key is not found.
-#### `delete_state`
+#### `delete_state`
```python
delete_state(self, key: str) -> None
@@ -653,7 +653,7 @@ delete_state(self, key: str) -> None
Delete a value from the session-scoped state store.
-#### `enable_components`
+#### `enable_components`
```python
enable_components(self) -> None
@@ -677,7 +677,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
-#### `disable_components`
+#### `disable_components`
```python
disable_components(self) -> None
@@ -701,7 +701,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
-#### `reset_visibility`
+#### `reset_visibility`
```python
reset_visibility(self) -> None
diff --git a/src/fastmcp/client/mixins/prompts.py b/src/fastmcp/client/mixins/prompts.py
index e7c8df80de..e8907c1946 100644
--- a/src/fastmcp/client/mixins/prompts.py
+++ b/src/fastmcp/client/mixins/prompts.py
@@ -70,12 +70,20 @@ async def list_prompts(self: Client) -> list[mcp.types.Prompt]:
"""
all_prompts: list[mcp.types.Prompt] = []
cursor: str | None = None
+ seen_cursors: set[str] = set()
while True:
result = await self.list_prompts_mcp(cursor=cursor)
all_prompts.extend(result.prompts)
- if result.nextCursor is None:
+ if not result.nextCursor:
break
+ if result.nextCursor in seen_cursors:
+ logger.warning(
+ f"[{self.name}] Server returned duplicate pagination cursor"
+ f" {result.nextCursor!r} for list_prompts; stopping pagination"
+ )
+ break
+ seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
return all_prompts
diff --git a/src/fastmcp/client/mixins/resources.py b/src/fastmcp/client/mixins/resources.py
index 86de910d9b..f49f861f80 100644
--- a/src/fastmcp/client/mixins/resources.py
+++ b/src/fastmcp/client/mixins/resources.py
@@ -69,12 +69,20 @@ async def list_resources(self: Client) -> list[mcp.types.Resource]:
"""
all_resources: list[mcp.types.Resource] = []
cursor: str | None = None
+ seen_cursors: set[str] = set()
while True:
result = await self.list_resources_mcp(cursor=cursor)
all_resources.extend(result.resources)
- if result.nextCursor is None:
+ if not result.nextCursor:
break
+ if result.nextCursor in seen_cursors:
+ logger.warning(
+ f"[{self.name}] Server returned duplicate pagination cursor"
+ f" {result.nextCursor!r} for list_resources; stopping pagination"
+ )
+ break
+ seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
return all_resources
@@ -119,12 +127,21 @@ async def list_resource_templates(self: Client) -> list[mcp.types.ResourceTempla
"""
all_templates: list[mcp.types.ResourceTemplate] = []
cursor: str | None = None
+ seen_cursors: set[str] = set()
while True:
result = await self.list_resource_templates_mcp(cursor=cursor)
all_templates.extend(result.resourceTemplates)
- if result.nextCursor is None:
+ if not result.nextCursor:
+ break
+ if result.nextCursor in seen_cursors:
+ logger.warning(
+ f"[{self.name}] Server returned duplicate pagination cursor"
+ f" {result.nextCursor!r} for list_resource_templates;"
+ " stopping pagination"
+ )
break
+ seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
return all_templates
diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py
index 557af20be8..f702e4938f 100644
--- a/src/fastmcp/client/mixins/tools.py
+++ b/src/fastmcp/client/mixins/tools.py
@@ -73,12 +73,20 @@ async def list_tools(self: Client) -> list[mcp.types.Tool]:
"""
all_tools: list[mcp.types.Tool] = []
cursor: str | None = None
+ seen_cursors: set[str] = set()
while True:
result = await self.list_tools_mcp(cursor=cursor)
all_tools.extend(result.tools)
- if result.nextCursor is None:
+ if not result.nextCursor:
break
+ if result.nextCursor in seen_cursors:
+ logger.warning(
+ f"[{self.name}] Server returned duplicate pagination cursor"
+ f" {result.nextCursor!r} for list_tools; stopping pagination"
+ )
+ break
+ seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
return all_tools
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index 9889aaee94..faa212a2ae 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -423,12 +423,16 @@ async def _paginate_list(
"""
all_items: list[Any] = []
cursor: str | None = None
+ seen_cursors: set[str] = set()
while True:
request = request_factory(cursor)
result = await call_method(request)
all_items.extend(extract_items(result))
- if result.nextCursor is None:
+ if not result.nextCursor:
break
+ if result.nextCursor in seen_cursors:
+ break
+ seen_cursors.add(result.nextCursor)
cursor = result.nextCursor
return all_items
diff --git a/tests/server/test_pagination.py b/tests/server/test_pagination.py
index c1387fb464..f90a763c0b 100644
--- a/tests/server/test_pagination.py
+++ b/tests/server/test_pagination.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+from unittest.mock import patch
+
+import mcp.types
import pytest
from mcp.shared.exceptions import McpError
@@ -241,3 +244,193 @@ def test_negative_page_size_raises(self) -> None:
ValueError, match="list_page_size must be a positive integer"
):
FastMCP(list_page_size=-1)
+
+
+class TestPaginationCycleDetection:
+ """Tests that auto-pagination terminates when the server returns cycling cursors."""
+
+ async def test_tools_constant_cursor_terminates(self) -> None:
+ """list_tools should stop if the server always returns the same cursor."""
+ server = FastMCP()
+
+ @server.tool
+ def my_tool() -> str:
+ return "ok"
+
+ async with Client(server) as client:
+ original = client.list_tools_mcp
+
+ async def returning_constant_cursor(
+ *,
+ cursor: str | None = None,
+ ) -> mcp.types.ListToolsResult:
+ result = await original(cursor=cursor)
+ result.nextCursor = "stuck"
+ return result
+
+ with patch.object(
+ client, "list_tools_mcp", side_effect=returning_constant_cursor
+ ):
+ tools = await client.list_tools()
+
+ # Should get tools from first page + one duplicate (the retry before
+ # detecting the cycle), then stop.
+ assert len(tools) == 2
+ assert all(t.name == "my_tool" for t in tools)
+
+ async def test_prompts_constant_cursor_terminates(self) -> None:
+ """list_prompts should stop if the server always returns the same cursor."""
+ server = FastMCP()
+
+ @server.prompt
+ def my_prompt() -> str:
+ return "text"
+
+ async with Client(server) as client:
+ original = client.list_prompts_mcp
+
+ async def returning_constant_cursor(
+ *,
+ cursor: str | None = None,
+ ) -> mcp.types.ListPromptsResult:
+ result = await original(cursor=cursor)
+ result.nextCursor = "stuck"
+ return result
+
+ with patch.object(
+ client, "list_prompts_mcp", side_effect=returning_constant_cursor
+ ):
+ prompts = await client.list_prompts()
+
+ assert len(prompts) == 2
+ assert all(p.name == "my_prompt" for p in prompts)
+
+ async def test_resources_constant_cursor_terminates(self) -> None:
+ """list_resources should stop if the server always returns the same cursor."""
+ server = FastMCP()
+
+ @server.resource("test://r")
+ def my_resource() -> str:
+ return "data"
+
+ async with Client(server) as client:
+ original = client.list_resources_mcp
+
+ async def returning_constant_cursor(
+ *,
+ cursor: str | None = None,
+ ) -> mcp.types.ListResourcesResult:
+ result = await original(cursor=cursor)
+ result.nextCursor = "stuck"
+ return result
+
+ with patch.object(
+ client, "list_resources_mcp", side_effect=returning_constant_cursor
+ ):
+ resources = await client.list_resources()
+
+ assert len(resources) == 2
+ assert all(r.name == "my_resource" for r in resources)
+
+ async def test_resource_templates_constant_cursor_terminates(self) -> None:
+ """list_resource_templates should stop if the server always returns the same cursor."""
+ server = FastMCP()
+
+ @server.resource("test://items/{item_id}")
+ def my_template(item_id: str) -> str:
+ return item_id
+
+ async with Client(server) as client:
+ original = client.list_resource_templates_mcp
+
+ async def returning_constant_cursor(
+ *,
+ cursor: str | None = None,
+ ) -> mcp.types.ListResourceTemplatesResult:
+ result = await original(cursor=cursor)
+ result.nextCursor = "stuck"
+ return result
+
+ with patch.object(
+ client,
+ "list_resource_templates_mcp",
+ side_effect=returning_constant_cursor,
+ ):
+ templates = await client.list_resource_templates()
+
+ assert len(templates) == 2
+
+ async def test_cycling_cursors_terminates(self) -> None:
+ """list_tools should stop if the server cycles through a set of cursors."""
+ server = FastMCP()
+
+ @server.tool
+ def my_tool() -> str:
+ return "ok"
+
+ async with Client(server) as client:
+ call_count = 0
+ original = client.list_tools_mcp
+
+ async def returning_cycling_cursor(
+ *,
+ cursor: str | None = None,
+ ) -> mcp.types.ListToolsResult:
+ nonlocal call_count
+ result = await original(cursor=cursor)
+ # Cycle through A -> B -> C -> A
+ cursors = ["A", "B", "C"]
+ result.nextCursor = cursors[call_count % 3]
+ call_count += 1
+ return result
+
+ with patch.object(
+ client, "list_tools_mcp", side_effect=returning_cycling_cursor
+ ):
+ tools = await client.list_tools()
+
+ # A, B, C seen, then A is a duplicate → 4 calls total
+ assert call_count == 4
+ assert len(tools) == 4
+
+ async def test_empty_string_cursor_terminates(self) -> None:
+ """list_tools should stop if the server returns an empty string cursor."""
+ server = FastMCP()
+
+ @server.tool
+ def my_tool() -> str:
+ return "ok"
+
+ async with Client(server) as client:
+ original = client.list_tools_mcp
+
+ async def returning_empty_cursor(
+ *,
+ cursor: str | None = None,
+ ) -> mcp.types.ListToolsResult:
+ result = await original(cursor=cursor)
+ result.nextCursor = ""
+ return result
+
+ with patch.object(
+ client, "list_tools_mcp", side_effect=returning_empty_cursor
+ ):
+ tools = await client.list_tools()
+
+ assert len(tools) == 1
+ assert tools[0].name == "my_tool"
+
+ async def test_normal_pagination_unaffected(self) -> None:
+ """Cycle detection should not interfere with normal pagination."""
+ server = FastMCP(list_page_size=10)
+
+ for i in range(25):
+
+ @server.tool(name=f"tool_{i}")
+ def make_tool() -> str:
+ return "ok"
+
+ async with Client(server) as client:
+ tools = await client.list_tools()
+ assert len(tools) == 25
+ assert len({t.name for t in tools}) == 25