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