fastmcp/tests/server/test_pagination.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

526 lines
18 KiB
Python

"""Tests for MCP pagination support."""
from __future__ import annotations
from unittest.mock import patch
import mcp.types
import pytest
from mcp.shared.exceptions import McpError
from fastmcp import Client, FastMCP
from fastmcp.utilities.pagination import CursorState, paginate_sequence
class TestCursorEncoding:
"""Tests for cursor encoding/decoding."""
def test_encode_decode_roundtrip(self) -> None:
"""Cursor should survive encode/decode roundtrip."""
state = CursorState(offset=100)
encoded = state.encode()
decoded = CursorState.decode(encoded)
assert decoded.offset == 100
def test_encode_produces_string(self) -> None:
"""Encoded cursor should be a string."""
state = CursorState(offset=50)
encoded = state.encode()
assert isinstance(encoded, str)
assert len(encoded) > 0
def test_decode_invalid_base64_raises(self) -> None:
"""Invalid base64 should raise ValueError."""
with pytest.raises(ValueError, match="Invalid cursor"):
CursorState.decode("not-valid-base64!!!")
def test_decode_invalid_json_raises(self) -> None:
"""Valid base64 but invalid JSON should raise ValueError."""
import base64
invalid = base64.urlsafe_b64encode(b"not json").decode()
with pytest.raises(ValueError, match="Invalid cursor"):
CursorState.decode(invalid)
def test_decode_missing_offset_raises(self) -> None:
"""JSON missing the offset key should raise ValueError."""
import base64
import json
invalid = base64.urlsafe_b64encode(json.dumps({"x": 1}).encode()).decode()
with pytest.raises(ValueError, match="Invalid cursor"):
CursorState.decode(invalid)
class TestPaginateSequence:
"""Tests for the paginate_sequence helper."""
def test_first_page_no_cursor(self) -> None:
"""First page should start from beginning."""
items = list(range(25))
page, cursor = paginate_sequence(items, None, 10)
assert page == list(range(10))
assert cursor is not None
def test_second_page_with_cursor(self) -> None:
"""Second page should continue from cursor."""
items = list(range(25))
_, cursor = paginate_sequence(items, None, 10)
page, next_cursor = paginate_sequence(items, cursor, 10)
assert page == list(range(10, 20))
assert next_cursor is not None
def test_last_page_returns_none_cursor(self) -> None:
"""Last page should return None cursor."""
items = list(range(25))
_, c1 = paginate_sequence(items, None, 10)
_, c2 = paginate_sequence(items, c1, 10)
page, next_cursor = paginate_sequence(items, c2, 10)
assert page == list(range(20, 25))
assert next_cursor is None
def test_empty_list(self) -> None:
"""Empty list should return empty page and no cursor."""
page, cursor = paginate_sequence([], None, 10)
assert page == []
assert cursor is None
def test_exact_page_size(self) -> None:
"""List exactly matching page size should return no cursor."""
items = list(range(10))
page, cursor = paginate_sequence(items, None, 10)
assert page == items
assert cursor is None
def test_smaller_than_page_size(self) -> None:
"""List smaller than page size should return all items."""
items = list(range(5))
page, cursor = paginate_sequence(items, None, 10)
assert page == items
assert cursor is None
def test_invalid_cursor_raises(self) -> None:
"""Invalid cursor should raise ValueError."""
with pytest.raises(ValueError, match="Invalid cursor"):
paginate_sequence([1, 2, 3], "invalid!", 10)
class TestServerPagination:
"""Integration tests for server pagination."""
async def test_tools_pagination_returns_all_tools(self) -> None:
"""Client should receive all tools across paginated requests."""
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
tool_names = {t.name for t in tools}
assert tool_names == {f"tool_{i}" for i in range(25)}
async def test_resources_pagination_returns_all_resources(self) -> None:
"""Client should receive all resources across paginated requests."""
server = FastMCP(list_page_size=10)
for i in range(25):
@server.resource(f"test://resource_{i}")
def make_resource() -> str:
return "data"
async with Client(server) as client:
resources = await client.list_resources()
assert len(resources) == 25
async def test_prompts_pagination_returns_all_prompts(self) -> None:
"""Client should receive all prompts across paginated requests."""
server = FastMCP(list_page_size=10)
for i in range(25):
@server.prompt(name=f"prompt_{i}")
def make_prompt() -> str:
return "text"
async with Client(server) as client:
prompts = await client.list_prompts()
assert len(prompts) == 25
async def test_manual_pagination(self) -> None:
"""Client can manually paginate using cursor."""
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:
# First page
result = await client.list_tools_mcp()
assert len(result.tools) == 10
assert result.nextCursor is not None
# Second page
result2 = await client.list_tools_mcp(cursor=result.nextCursor)
assert len(result2.tools) == 10
assert result2.nextCursor is not None
# Third (last) page
result3 = await client.list_tools_mcp(cursor=result2.nextCursor)
assert len(result3.tools) == 5
assert result3.nextCursor is None
async def test_invalid_cursor_returns_error(self) -> None:
"""Server should return MCP error for invalid cursor."""
server = FastMCP(list_page_size=10)
@server.tool
def my_tool() -> str:
return "ok"
async with Client(server) as client:
with pytest.raises(McpError) as exc:
await client.list_tools_mcp(cursor="invalid!")
assert exc.value.error.code == -32602
async def test_no_pagination_when_disabled(self) -> None:
"""Without list_page_size, all items returned at once."""
server = FastMCP() # No pagination
for i in range(25):
@server.tool(name=f"tool_{i}")
def make_tool() -> str:
return "ok"
async with Client(server) as client:
result = await client.list_tools_mcp()
assert len(result.tools) == 25
assert result.nextCursor is None
async def test_pagination_exact_page_boundary(self) -> None:
"""Test pagination at exact page boundaries."""
server = FastMCP(list_page_size=10)
for i in range(20): # Exactly 2 pages
@server.tool(name=f"tool_{i}")
def make_tool() -> str:
return "ok"
async with Client(server) as client:
# First page
result = await client.list_tools_mcp()
assert len(result.tools) == 10
assert result.nextCursor is not None
# Second (last) page
result2 = await client.list_tools_mcp(cursor=result.nextCursor)
assert len(result2.tools) == 10
assert result2.nextCursor is None
class TestPageSizeValidation:
"""Tests for list_page_size validation."""
def test_zero_page_size_raises(self) -> None:
"""Zero page size should raise ValueError."""
with pytest.raises(
ValueError, match="list_page_size must be a positive integer"
):
FastMCP(list_page_size=0)
def test_negative_page_size_raises(self) -> None:
"""Negative page size should raise ValueError."""
with pytest.raises(
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_tools_raises_on_auto_pagination_limit(self) -> None:
"""list_tools should raise RuntimeError after exceeding max_pages."""
server = FastMCP()
@server.tool
def my_tool() -> str:
return "ok"
async with Client(server) as client:
original = client.list_tools_mcp
call_count = 0
async def returning_unique_cursor(
*,
cursor: str | None = None,
) -> mcp.types.ListToolsResult:
nonlocal call_count
result = await original(cursor=cursor)
call_count += 1
result.nextCursor = f"cursor-{call_count}"
return result
with (
patch.object(
client, "list_tools_mcp", side_effect=returning_unique_cursor
),
pytest.raises(RuntimeError, match="auto-pagination limit"),
):
await client.list_tools(max_pages=5)
async def test_resources_raises_on_auto_pagination_limit(self) -> None:
"""list_resources should raise RuntimeError after exceeding max_pages."""
server = FastMCP()
@server.resource("test://r")
def my_resource() -> str:
return "data"
async with Client(server) as client:
original = client.list_resources_mcp
call_count = 0
async def returning_unique_cursor(
*,
cursor: str | None = None,
) -> mcp.types.ListResourcesResult:
nonlocal call_count
result = await original(cursor=cursor)
call_count += 1
result.nextCursor = f"cursor-{call_count}"
return result
with (
patch.object(
client, "list_resources_mcp", side_effect=returning_unique_cursor
),
pytest.raises(RuntimeError, match="auto-pagination limit"),
):
await client.list_resources(max_pages=5)
async def test_prompts_raises_on_auto_pagination_limit(self) -> None:
"""list_prompts should raise RuntimeError after exceeding max_pages."""
server = FastMCP()
@server.prompt
def my_prompt() -> str:
return "text"
async with Client(server) as client:
original = client.list_prompts_mcp
call_count = 0
async def returning_unique_cursor(
*,
cursor: str | None = None,
) -> mcp.types.ListPromptsResult:
nonlocal call_count
result = await original(cursor=cursor)
call_count += 1
result.nextCursor = f"cursor-{call_count}"
return result
with (
patch.object(
client, "list_prompts_mcp", side_effect=returning_unique_cursor
),
pytest.raises(RuntimeError, match="auto-pagination limit"),
):
await client.list_prompts(max_pages=5)
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