This is an automated email from the ASF dual-hosted git repository.

FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git


The following commit(s) were added to refs/heads/master by this push:
     new 3151ed7  feat: paginate MCP list operations (#141)
3151ed7 is described below

commit 3151ed792eb62261c249e56fe4efe46885e34c40
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 10:00:46 2026 +0800

    feat: paginate MCP list operations (#141)
---
 .env.example                                   |   3 +
 CHANGELOG.md                                   |   2 +
 README.md                                      |  19 ++
 doris_mcp_server/main.py                       |   2 +
 doris_mcp_server/multiworker_app.py            |   1 +
 doris_mcp_server/pagination.py                 | 202 ++++++++++++++++++
 doris_mcp_server/protocol.py                   |  98 ++++++++-
 doris_mcp_server/tools/resources_manager.py    |  66 +++++-
 doris_mcp_server/utils/config.py               |  12 ++
 test/integration/test_real_doris_transports.py | 101 ++++++++-
 test/protocol/pagination_fixture.py            | 138 +++++++++++++
 test/protocol/pagination_stdio_server.py       |  39 ++++
 test/protocol/test_mcp_v2_protocol.py          |  32 +--
 test/protocol/test_multiworker_config.py       |  18 ++
 test/protocol/test_protocol_pagination.py      | 274 +++++++++++++++++++++++++
 test/tools/test_resources_manager_cache.py     |  43 +++-
 16 files changed, 1008 insertions(+), 42 deletions(-)

diff --git a/.env.example b/.env.example
index 1cdaba6..6c11165 100644
--- a/.env.example
+++ b/.env.example
@@ -458,6 +458,9 @@ SERVER_PORT=3000
 # /mcp/legacy. Stdio compatibility is unaffected by this switch.
 ENABLE_LEGACY_HTTP_ADAPTER=false
 
+# Maximum resources, tools, or prompts returned in one list page (1-1000).
+MCP_LIST_PAGE_SIZE=100
+
 # Temporary files directory
 TEMP_FILES_DIR=tmp
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 667fc95..e363112 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,8 @@ under **Unreleased** until a new version is selected and 
published.
   client/redirect host context.
 - Per-request client capability enforcement, cache hints, typed protocol
   errors, and deterministic product identity.
+- Permission-bound cursor pagination for resources, tools, and prompts on
+  Streamable HTTP and stdio.
 - Real Doris process tests covering Streamable HTTP and stdio.
 
 ### Changed
diff --git a/README.md b/README.md
index 6186b8f..fc1a68a 100644
--- a/README.md
+++ b/README.md
@@ -208,6 +208,9 @@ export DORIS_PASSWORD="your_password"
 # identified 2025-11-25 client still needs /mcp/legacy.
 export ENABLE_LEGACY_HTTP_ADAPTER=false
 
+# Bound each resources/list, tools/list, and prompts/list response.
+export MCP_LIST_PAGE_SIZE=100
+
 # Token Management Interface (Security-Critical)
 export TOKEN_ADMIN="$(python -c 'import secrets; 
print(secrets.token_urlsafe(32))')"
 export TOKEN_MANAGEMENT_ADMIN_TOKEN="$(python -c 'import secrets; 
print(secrets.token_urlsafe(32))')"
@@ -283,6 +286,8 @@ cp .env.example .env
     *   `ENABLE_LEGACY_HTTP_ADAPTER`: Expose the isolated
         `2025-11-25` migration adapter at `/mcp/legacy` (default: false);
         modern traffic always uses `POST /mcp`
+    *   `MCP_LIST_PAGE_SIZE`: Maximum resources, tools, or prompts returned
+        per protocol page (default: 100; range: 1-1000)
 *   **Authentication Configuration (Enhanced in v0.6.0)**:
     *   `ENABLE_TOKEN_AUTH`: Enable token-based authentication (default: false)
     *   `ENABLE_JWT_AUTH`: Enable JWT authentication (default: false)
@@ -541,6 +546,20 @@ Stdio carries the same JSON-RPC request metadata in the 
message body, but it
 does not use HTTP headers. Do not write logs or other diagnostics to stdout in
 stdio mode; stdout is reserved for MCP protocol messages.
 
+### List Pagination
+
+`resources/list`, `tools/list`, and `prompts/list` return at most
+`MCP_LIST_PAGE_SIZE` entries per response on both Streamable HTTP and stdio.
+Results are ordered by their stable resource URI, tool name, or prompt name.
+When `nextCursor` is present, pass that opaque value as the next request's
+`cursor`; do not parse or construct cursors.
+
+A cursor is bound to its list type, the current visible-list snapshot, and the
+current authorization context. Reusing it for another list, after visibility
+changes, or under another principal returns `Invalid Params`. Restart the
+listing without a cursor in that case. This prevents a multi-page traversal
+from silently duplicating, dropping, or crossing permission-scoped entries.
+
 ### Migrating from MCP 2025-11-25
 
 1. Upgrade the client to a `2026-07-28`-capable MCP SDK.
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index 095311f..f70d62b 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -83,6 +83,7 @@ def _multiworker_environment(
         "ENABLE_LEGACY_HTTP_ADAPTER": str(
             config.enable_legacy_http_adapter
         ).lower(),
+        "MCP_LIST_PAGE_SIZE": str(config.mcp_list_page_size),
         "SERVER_NAME": config.server_name,
         "TRANSPORT": "http",
         "WORKERS": str(workers),
@@ -134,6 +135,7 @@ class DorisServer:
             name=config.server_name,
             version=config.server_version,
             logger=self.logger,
+            list_page_size=config.mcp_list_page_size,
         )
 
     async def start_stdio(self) -> None:
diff --git a/doris_mcp_server/multiworker_app.py 
b/doris_mcp_server/multiworker_app.py
index 0d64b4b..38dc6e7 100644
--- a/doris_mcp_server/multiworker_app.py
+++ b/doris_mcp_server/multiworker_app.py
@@ -163,6 +163,7 @@ async def initialize_worker() -> None:
             name=config.server_name,
             version=config.server_version,
             logger=logger,
+            list_page_size=config.mcp_list_page_size,
         )
 
         # Create the exact modern/legacy HTTP boundary for this worker.
diff --git a/doris_mcp_server/pagination.py b/doris_mcp_server/pagination.py
new file mode 100644
index 0000000..11c4abc
--- /dev/null
+++ b/doris_mcp_server/pagination.py
@@ -0,0 +1,202 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Stateless, permission-bound pagination for MCP list operations."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass
+from hashlib import sha256
+from hmac import compare_digest
+from typing import Any, Generic, Literal, TypeVar
+
+from .utils.security import AuthContext
+
+DEFAULT_LIST_PAGE_SIZE = 100
+MAX_LIST_PAGE_SIZE = 1000
+_CURSOR_VERSION = 1
+_MAX_CURSOR_LENGTH = 2048
+_CURSOR_FIELDS = {
+    "version",
+    "collection",
+    "after",
+    "snapshot",
+    "authorization",
+}
+
+CursorCollection = Literal["resources", "tools", "prompts"]
+T = TypeVar("T")
+
+
+class PaginationCursorError(ValueError):
+    """An invalid cursor supplied by an MCP client."""
+
+    def __init__(self, reason: str) -> None:
+        super().__init__(reason)
+        self.reason = reason
+
+
+@dataclass(frozen=True)
+class PaginationPage(Generic[T]):
+    """One deterministic page and the opaque cursor for its successor."""
+
+    items: list[T]
+    next_cursor: str | None
+
+
+def _canonical_json(value: Any) -> bytes:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        separators=(",", ":"),
+        sort_keys=True,
+    ).encode("utf-8")
+
+
+def _digest(value: Any) -> str:
+    return sha256(_canonical_json(value)).hexdigest()
+
+
+def _authorization_fingerprint(auth_context: AuthContext | None) -> str:
+    if auth_context is None:
+        return _digest({"authMethod": "anonymous", "principal": "anonymous"})
+
+    security_level = getattr(auth_context.security_level, "value", "")
+    return _digest(
+        {
+            "authMethod": auth_context.auth_method,
+            "tokenId": auth_context.token_id,
+            "userId": auth_context.user_id,
+            "roles": sorted(auth_context.roles),
+            "permissions": sorted(auth_context.permissions),
+            "securityLevel": security_level,
+            "dorisUser": auth_context.doris_user,
+            "oauthClientId": auth_context.oauth_client_id,
+            "oauthScopes": sorted(auth_context.oauth_scopes),
+            "oauthTokenId": auth_context.oauth_token_id,
+            "oauthIssuer": auth_context.oauth_issuer,
+            "oauthResource": auth_context.oauth_resource,
+            "oauthAudiences": sorted(auth_context.oauth_audiences),
+            "poolKey": auth_context.pool_key,
+            "dorisOAuthDatabaseTools": {
+                "enabled": auth_context.doris_oauth_db_tools_enabled,
+                "allowlist": 
sorted(auth_context.doris_oauth_db_tool_allowlist),
+            },
+            "dorisOAuthQueryTools": {
+                "enabled": auth_context.doris_oauth_query_tools_enabled,
+                "allowlist": 
sorted(auth_context.doris_oauth_query_tool_allowlist),
+            },
+            "dorisOAuthExplainTools": {
+                "enabled": auth_context.doris_oauth_explain_tools_enabled,
+                "allowlist": 
sorted(auth_context.doris_oauth_explain_tool_allowlist),
+            },
+        }
+    )
+
+
+def _encode_cursor(payload: dict[str, Any]) -> str:
+    return (
+        
base64.urlsafe_b64encode(_canonical_json(payload)).decode("ascii").rstrip("=")
+    )
+
+
+def _decode_cursor(cursor: str) -> dict[str, Any]:
+    if not cursor or len(cursor) > _MAX_CURSOR_LENGTH:
+        raise PaginationCursorError("invalid")
+    try:
+        padding = "=" * (-len(cursor) % 4)
+        raw = base64.b64decode(
+            (cursor + padding).encode("ascii"),
+            altchars=b"-_",
+            validate=True,
+        )
+        payload = json.loads(raw.decode("utf-8"))
+    except (
+        UnicodeEncodeError,
+        UnicodeDecodeError,
+        binascii.Error,
+        json.JSONDecodeError,
+    ) as exc:
+        raise PaginationCursorError("invalid") from exc
+
+    if not isinstance(payload, dict) or set(payload) != _CURSOR_FIELDS:
+        raise PaginationCursorError("invalid")
+    if payload.get("version") != _CURSOR_VERSION:
+        raise PaginationCursorError("invalid")
+    if not all(
+        isinstance(payload.get(field), str)
+        for field in ("collection", "after", "snapshot", "authorization")
+    ):
+        raise PaginationCursorError("invalid")
+    return payload
+
+
+def paginate(
+    items: Sequence[T],
+    *,
+    collection: CursorCollection,
+    cursor: str | None,
+    page_size: int,
+    identifier: Callable[[T], str],
+    auth_context: AuthContext | None,
+) -> PaginationPage[T]:
+    """Return a deterministic keyset page or reject an unsafe continuation."""
+    if not 1 <= page_size <= MAX_LIST_PAGE_SIZE:
+        raise ValueError(f"page_size must be in the range 
1-{MAX_LIST_PAGE_SIZE}")
+
+    ordered = sorted(items, key=identifier)
+    identifiers = [identifier(item) for item in ordered]
+    if len(identifiers) != len(set(identifiers)):
+        raise RuntimeError(f"{collection} list contains duplicate identifiers")
+
+    snapshot = _digest(identifiers)
+    authorization = _authorization_fingerprint(auth_context)
+    start = 0
+
+    if cursor is not None:
+        payload = _decode_cursor(cursor)
+        if payload["collection"] != collection:
+            raise PaginationCursorError("wrong_collection")
+        if not compare_digest(payload["authorization"], authorization):
+            raise PaginationCursorError("authorization_context_changed")
+        if not compare_digest(payload["snapshot"], snapshot):
+            raise PaginationCursorError("stale")
+        try:
+            start = identifiers.index(payload["after"]) + 1
+        except ValueError as exc:
+            raise PaginationCursorError("stale") from exc
+        if start >= len(ordered):
+            raise PaginationCursorError("invalid")
+
+    page_items = ordered[start : start + page_size]
+    end = start + len(page_items)
+    next_cursor = None
+    if page_items and end < len(ordered):
+        next_cursor = _encode_cursor(
+            {
+                "version": _CURSOR_VERSION,
+                "collection": collection,
+                "after": identifiers[end - 1],
+                "snapshot": snapshot,
+                "authorization": authorization,
+            }
+        )
+
+    return PaginationPage(items=page_items, next_cursor=next_cursor)
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 0712879..082778d 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -20,8 +20,8 @@ from __future__ import annotations
 
 import json
 import logging
-from collections.abc import Iterable, Mapping
-from typing import Any, Protocol
+from collections.abc import Callable, Iterable, Mapping, Sequence
+from typing import Any, Protocol, TypeVar
 
 from mcp.server import Server, ServerRequestContext
 from mcp.server.caching import CacheHint
@@ -53,6 +53,14 @@ from mcp.types import (
 )
 
 from .auth.operation_policy import OperationAuthorizationError, 
authorize_operation
+from .pagination import (
+    DEFAULT_LIST_PAGE_SIZE,
+    MAX_LIST_PAGE_SIZE,
+    CursorCollection,
+    PaginationCursorError,
+    PaginationPage,
+    paginate,
+)
 from .utils.redaction import (
     redact_error_payload,
     redact_sensitive_text,
@@ -60,6 +68,8 @@ from .utils.redaction import (
 )
 from .utils.security import get_current_auth_context
 
+_ListItemT = TypeVar("_ListItemT")
+
 
 class ResourcesManager(Protocol):
     async def list_resources(self) -> list[Resource]: ...
@@ -186,6 +196,31 @@ def _decode_resource_request_error(payload: str) -> 
tuple[str, str] | None:
     return error_code, message
 
 
+def _paginate_list_or_raise(
+    items: Sequence[_ListItemT],
+    *,
+    collection: CursorCollection,
+    cursor: str | None,
+    page_size: int,
+    identifier: Callable[[_ListItemT], str],
+) -> PaginationPage[_ListItemT]:
+    try:
+        return paginate(
+            items,
+            collection=collection,
+            cursor=cursor,
+            page_size=page_size,
+            identifier=identifier,
+            auth_context=get_current_auth_context(),
+        )
+    except PaginationCursorError as exc:
+        raise MCPError(
+            code=INVALID_PARAMS,
+            message="Invalid pagination cursor",
+            data={"cursorError": exc.reason},
+        ) from exc
+
+
 def create_doris_mcp_server(
     *,
     resources_manager: ResourcesManager,
@@ -194,20 +229,37 @@ def create_doris_mcp_server(
     name: str,
     version: str,
     logger: logging.Logger,
+    list_page_size: int = DEFAULT_LIST_PAGE_SIZE,
     required_client_capabilities: Mapping[str, ClientCapabilities] | None = 
None,
     required_tool_capabilities: Mapping[str, ClientCapabilities] | None = None,
 ) -> Server:
     """Create the one low-level SDK v2 server used by every transport."""
+    if not 1 <= list_page_size <= MAX_LIST_PAGE_SIZE:
+        raise ValueError(f"list_page_size must be in the range 
1-{MAX_LIST_PAGE_SIZE}")
 
     async def list_resources(
         ctx: ServerRequestContext,
         params: PaginatedRequestParams | None,
     ) -> ListResourcesResult:
-        del ctx, params
+        del ctx
         authorize_operation(get_current_auth_context(), "list_resources")
         resources = await resources_manager.list_resources()
-        logger.info("Returning %d resources", len(resources))
-        return ListResourcesResult(resources=resources)
+        page = _paginate_list_or_raise(
+            resources,
+            collection="resources",
+            cursor=params.cursor if params else None,
+            page_size=list_page_size,
+            identifier=lambda resource: str(resource.uri),
+        )
+        logger.info(
+            "Returning %d of %d resources",
+            len(page.items),
+            len(resources),
+        )
+        return ListResourcesResult(
+            resources=page.items,
+            next_cursor=page.next_cursor,
+        )
 
     async def read_resource(
         ctx: ServerRequestContext,
@@ -242,11 +294,21 @@ def create_doris_mcp_server(
         ctx: ServerRequestContext,
         params: PaginatedRequestParams | None,
     ) -> ListToolsResult:
-        del ctx, params
+        del ctx
         authorize_operation(get_current_auth_context(), "list_tools")
         tools = await tools_manager.list_tools()
-        logger.info("Returning %d tools", len(tools))
-        return ListToolsResult(tools=tools)
+        page = _paginate_list_or_raise(
+            tools,
+            collection="tools",
+            cursor=params.cursor if params else None,
+            page_size=list_page_size,
+            identifier=lambda tool: tool.name,
+        )
+        logger.info("Returning %d of %d tools", len(page.items), len(tools))
+        return ListToolsResult(
+            tools=page.items,
+            next_cursor=page.next_cursor,
+        )
 
     async def call_tool(
         ctx: ServerRequestContext,
@@ -279,11 +341,25 @@ def create_doris_mcp_server(
         ctx: ServerRequestContext,
         params: PaginatedRequestParams | None,
     ) -> ListPromptsResult:
-        del ctx, params
+        del ctx
         authorize_operation(get_current_auth_context(), "list_prompts")
         prompts = await prompts_manager.list_prompts()
-        logger.info("Returning %d prompts", len(prompts))
-        return ListPromptsResult(prompts=prompts)
+        page = _paginate_list_or_raise(
+            prompts,
+            collection="prompts",
+            cursor=params.cursor if params else None,
+            page_size=list_page_size,
+            identifier=lambda prompt: prompt.name,
+        )
+        logger.info(
+            "Returning %d of %d prompts",
+            len(page.items),
+            len(prompts),
+        )
+        return ListPromptsResult(
+            prompts=page.items,
+            next_cursor=page.next_cursor,
+        )
 
     async def get_prompt(
         ctx: ServerRequestContext,
diff --git a/doris_mcp_server/tools/resources_manager.py 
b/doris_mcp_server/tools/resources_manager.py
index a7f231e..b6de177 100644
--- a/doris_mcp_server/tools/resources_manager.py
+++ b/doris_mcp_server/tools/resources_manager.py
@@ -23,6 +23,7 @@ import json
 from collections.abc import AsyncIterator
 from contextlib import asynccontextmanager
 from datetime import datetime
+from hashlib import sha256
 from typing import Any, cast
 from urllib.parse import quote, unquote
 
@@ -75,10 +76,16 @@ class ViewMetadata:
 class MetadataCache:
     """Metadata cache manager"""
 
-    def __init__(self, ttl_seconds: int = 300, enabled: bool = False):
+    def __init__(
+        self,
+        ttl_seconds: int = 300,
+        enabled: bool = False,
+        max_entries: int = 128,
+    ):
         self.cache: dict[str, tuple[Any, float]] = {}
         self.ttl = ttl_seconds
         self.enabled = enabled
+        self.max_entries = max_entries
 
     async def get(self, key: str) -> Any | None:
         if not self.enabled:
@@ -94,6 +101,9 @@ class MetadataCache:
     async def set(self, key: str, value: Any) -> None:
         if not self.enabled:
             return
+        if key not in self.cache and len(self.cache) >= self.max_entries:
+            oldest_key = min(self.cache, key=lambda item: self.cache[item][1])
+            del self.cache[oldest_key]
         self.cache[key] = (value, datetime.now().timestamp())
 
 
@@ -132,10 +142,52 @@ class DorisResourcesManager:
 
     def __init__(self, connection_manager: DorisConnectionManager):
         self.connection_manager = connection_manager
-        # Resource metadata cache is disabled until it is identity-aware.
-        # Static token-bound DB and Doris OAuth can route to different Doris
-        # users; global cache keys would leak metadata across identities.
-        self.metadata_cache = MetadataCache(enabled=False)
+        self.metadata_cache = MetadataCache(enabled=True)
+
+    def _metadata_cache_scope(self) -> str:
+        auth_context = get_auth_context()
+        original_config = getattr(
+            self.connection_manager,
+            "original_db_config",
+            {},
+        )
+        endpoint = {
+            field: original_config.get(field)
+            for field in ("host", "port", "user", "database")
+        }
+
+        token_manager = getattr(self.connection_manager, "token_manager", None)
+        token = getattr(auth_context, "token", "") if auth_context else ""
+        if token_manager and token:
+            token_config = token_manager.get_database_config_by_token(token)
+            if token_config:
+                endpoint = {
+                    field: getattr(token_config, field, None)
+                    for field in ("host", "port", "user", "database")
+                }
+
+        identity = {
+            "authMethod": getattr(auth_context, "auth_method", ""),
+            "tokenId": getattr(auth_context, "token_id", ""),
+            "userId": getattr(auth_context, "user_id", ""),
+            "roles": sorted(getattr(auth_context, "roles", [])),
+            "permissions": sorted(getattr(auth_context, "permissions", [])),
+            "dorisUser": getattr(auth_context, "doris_user", ""),
+            "oauthClientId": getattr(auth_context, "oauth_client_id", ""),
+            "oauthScopes": sorted(getattr(auth_context, "oauth_scopes", [])),
+            "oauthResource": getattr(auth_context, "oauth_resource", ""),
+            "oauthAudiences": sorted(
+                getattr(auth_context, "oauth_audiences", [])
+            ),
+            "poolKey": getattr(auth_context, "pool_key", ""),
+        }
+        payload = json.dumps(
+            {"endpoint": endpoint, "identity": identity},
+            ensure_ascii=False,
+            separators=(",", ":"),
+            sort_keys=True,
+        )
+        return sha256(payload.encode("utf-8")).hexdigest()
 
     def _is_doris_oauth_context(self) -> bool:
         return getattr(get_auth_context(), "auth_method", "") == "doris_oauth"
@@ -364,7 +416,7 @@ class DorisResourcesManager:
 
     async def _get_table_metadata(self) -> list[TableMetadata]:
         """Get metadata for all tables"""
-        cache_key = "table_metadata"
+        cache_key = f"{self._metadata_cache_scope()}:table_metadata"
         cached = await self.metadata_cache.get(cache_key)
         if cached:
             return cast(list[TableMetadata], cached)
@@ -467,7 +519,7 @@ class DorisResourcesManager:
 
     async def _get_view_metadata(self) -> list[ViewMetadata]:
         """Get metadata for all views"""
-        cache_key = "view_metadata"
+        cache_key = f"{self._metadata_cache_scope()}:view_metadata"
         cached = await self.metadata_cache.get(cache_key)
         if cached:
             return cast(list[ViewMetadata], cached)
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 64d676c..00dd3dc 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -603,6 +603,7 @@ class DorisConfig:
     mcp_allowed_hosts: list[str] = field(default_factory=list)
     mcp_allowed_origins: list[str] = field(default_factory=list)
     enable_legacy_http_adapter: bool = False
+    mcp_list_page_size: int = 100
     transport: str = "stdio"
     workers: int = 1
 
@@ -1148,6 +1149,12 @@ class DorisConfig:
                 os.getenv("ENABLE_LEGACY_HTTP_ADAPTER", "false").lower() == 
"true"
             )
             _mark_source(config, "enable_legacy_http_adapter", "env")
+        if "MCP_LIST_PAGE_SIZE" in os.environ:
+            config.mcp_list_page_size = _env_int(
+                "MCP_LIST_PAGE_SIZE",
+                config.mcp_list_page_size,
+            )
+            _mark_source(config, "mcp_list_page_size", "env")
         config.temp_files_dir = os.getenv("TEMP_FILES_DIR", 
config.temp_files_dir)
 
         return config
@@ -1165,6 +1172,7 @@ class DorisConfig:
             "mcp_allowed_hosts",
             "mcp_allowed_origins",
             "enable_legacy_http_adapter",
+            "mcp_list_page_size",
             "temp_files_dir",
             "transport",
             "workers",
@@ -1239,6 +1247,7 @@ class DorisConfig:
             "mcp_allowed_hosts": self.mcp_allowed_hosts,
             "mcp_allowed_origins": self.mcp_allowed_origins,
             "enable_legacy_http_adapter": self.enable_legacy_http_adapter,
+            "mcp_list_page_size": self.mcp_list_page_size,
             "temp_files_dir": self.temp_files_dir,
             "database": {
                 "host": self.database.host,
@@ -1433,6 +1442,9 @@ class DorisConfig:
         if self.database.http_max_response_bytes <= 0:
             errors.append("Doris HTTP response byte limit must be greater than 
0")
 
+        if not 1 <= self.mcp_list_page_size <= 1000:
+            errors.append("MCP list page size must be in the range 1-1000")
+
         # Validate security configuration
         if self.security.auth_type not in ["token", "basic", "oauth", "jwt"]:
             errors.append("Authentication type must be one of token, basic, 
oauth, or jwt")
diff --git a/test/integration/test_real_doris_transports.py 
b/test/integration/test_real_doris_transports.py
index 73a5b0a..0a76f1c 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -288,17 +288,25 @@ async def _http_process(environment: dict[str, str]) -> 
AsyncIterator[str]:
 
 
 @asynccontextmanager
-async def _http_client(environment: dict[str, str]) -> AsyncIterator[Client]:
+async def _http_client(
+    environment: dict[str, str],
+    *,
+    read_timeout_seconds: int = 15,
+) -> AsyncIterator[Client]:
     async with _http_process(environment) as base_url:
         async with Client(
             f"{base_url}/mcp",
-            read_timeout_seconds=15,
+            read_timeout_seconds=read_timeout_seconds,
         ) as client:
             yield client
 
 
 @asynccontextmanager
-async def _stdio_client(environment: dict[str, str]) -> AsyncIterator[Client]:
+async def _stdio_client(
+    environment: dict[str, str],
+    *,
+    read_timeout_seconds: int = 15,
+) -> AsyncIterator[Client]:
     parameters = StdioServerParameters(
         command=sys.executable,
         args=["-m", "doris_mcp_server", "--transport", "stdio"],
@@ -311,7 +319,7 @@ async def _stdio_client(environment: dict[str, str]) -> 
AsyncIterator[Client]:
             client = await stack.enter_async_context(
                 Client(
                     stdio_client(parameters, errlog=log_file),
-                    read_timeout_seconds=15,
+                    read_timeout_seconds=read_timeout_seconds,
                 )
             )
         except Exception:
@@ -329,10 +337,18 @@ async def _stdio_client(environment: dict[str, str]) -> 
AsyncIterator[Client]:
 def _transport_client(
     transport: str,
     environment: dict[str, str],
+    *,
+    read_timeout_seconds: int = 15,
 ) -> Any:
     if transport == "http":
-        return _http_client(environment)
-    return _stdio_client(environment)
+        return _http_client(
+            environment,
+            read_timeout_seconds=read_timeout_seconds,
+        )
+    return _stdio_client(
+        environment,
+        read_timeout_seconds=read_timeout_seconds,
+    )
 
 
 async def _exec_query(
@@ -353,6 +369,36 @@ async def _exec_query(
     return result, result.structured_content
 
 
+async def _collect_list_pages(
+    list_method: Any,
+    *,
+    result_field: str,
+    identifier: Any,
+    max_page_size: int,
+) -> tuple[list[str], int]:
+    cursor = None
+    seen_cursors: set[str] = set()
+    identifiers: list[str] = []
+    page_count = 0
+
+    while True:
+        result = await list_method(cursor=cursor, cache_mode="bypass")
+        page = getattr(result, result_field)
+        assert 0 < len(page) <= max_page_size
+        identifiers.extend(identifier(item) for item in page)
+        page_count += 1
+
+        cursor = result.next_cursor
+        if cursor is None:
+            break
+        assert cursor not in seen_cursors
+        seen_cursors.add(cursor)
+
+    assert identifiers == sorted(identifiers)
+    assert len(identifiers) == len(set(identifiers))
+    return identifiers, page_count
+
+
 async def test_real_doris_liveness_and_readiness(
     doris_sandbox: DorisSandbox,
 ) -> None:
@@ -379,6 +425,49 @@ async def test_real_doris_liveness_and_readiness(
     }
 
 
[email protected]("transport", ["http", "stdio"])
+async def test_real_doris_protocol_lists_paginate_without_loss(
+    transport: str,
+    doris_sandbox: DorisSandbox,
+) -> None:
+    environment = _server_environment(
+        doris_sandbox.settings,
+        user=doris_sandbox.settings.user,
+        password=doris_sandbox.settings.password,
+    )
+    page_size = 100
+    environment["MCP_LIST_PAGE_SIZE"] = str(page_size)
+    results: dict[str, tuple[list[str], int]] = {}
+
+    async with _transport_client(
+        transport,
+        environment,
+        read_timeout_seconds=60,
+    ) as client:
+        for list_method, result_field, identifier in (
+            (
+                client.list_resources,
+                "resources",
+                lambda resource: str(resource.uri),
+            ),
+            (client.list_tools, "tools", lambda tool: tool.name),
+            (client.list_prompts, "prompts", lambda prompt: prompt.name),
+        ):
+            identifiers, page_count = await _collect_list_pages(
+                list_method,
+                result_field=result_field,
+                identifier=identifier,
+                max_page_size=page_size,
+            )
+            results[result_field] = (identifiers, page_count)
+
+    resource_identifiers, resource_page_count = results["resources"]
+    assert len(resource_identifiers) > page_size
+    assert resource_page_count > 1
+    assert results["tools"][0]
+    assert results["prompts"][0]
+
+
 @pytest.mark.parametrize("transport", ["http", "stdio"])
 async def test_real_doris_read_write_permission_timeout_and_recovery(
     transport: str,
diff --git a/test/protocol/pagination_fixture.py 
b/test/protocol/pagination_fixture.py
new file mode 100644
index 0000000..3aca503
--- /dev/null
+++ b/test/protocol/pagination_fixture.py
@@ -0,0 +1,138 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Static managers used to verify MCP list pagination across transports."""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+from mcp.types import (
+    GetPromptResult,
+    Prompt,
+    PromptMessage,
+    Resource,
+    TextContent,
+    Tool,
+)
+
+from doris_mcp_server import __version__
+from doris_mcp_server.protocol import create_doris_mcp_server
+
+RESOURCE_URIS = [
+    "doris://table/alpha",
+    "doris://table/bravo",
+    "doris://table/charlie",
+    "doris://table/delta",
+    "doris://table/echo",
+]
+TOOL_NAMES = ["alpha", "bravo", "charlie", "delta", "echo"]
+PROMPT_NAMES = ["alpha", "bravo", "charlie", "delta", "echo"]
+
+
+@dataclass
+class PaginationManagers:
+    resources: PaginationResourcesManager
+    tools: PaginationToolsManager
+    prompts: PaginationPromptsManager
+
+
+class PaginationResourcesManager:
+    def __init__(self) -> None:
+        self.resources = [
+            Resource(
+                uri=uri,
+                name=uri.rsplit("/", 1)[-1],
+                mime_type="application/json",
+            )
+            for uri in reversed(RESOURCE_URIS)
+        ]
+
+    async def list_resources(self) -> list[Resource]:
+        return list(self.resources)
+
+    async def read_resource(self, uri: str) -> str:
+        return json.dumps({"uri": uri})
+
+
+class PaginationToolsManager:
+    def __init__(self) -> None:
+        self.tools = [
+            Tool(
+                name=name,
+                description=f"{name} test tool",
+                input_schema={"type": "object", "properties": {}},
+            )
+            for name in reversed(TOOL_NAMES)
+        ]
+
+    async def list_tools(self) -> list[Tool]:
+        return list(self.tools)
+
+    async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
+        return json.dumps({"name": name, "arguments": arguments})
+
+
+class PaginationPromptsManager:
+    def __init__(self) -> None:
+        self.prompts = [
+            Prompt(name=name, description=f"{name} test prompt")
+            for name in reversed(PROMPT_NAMES)
+        ]
+
+    async def list_prompts(self) -> list[Prompt]:
+        return list(self.prompts)
+
+    async def get_prompt(
+        self,
+        name: str,
+        arguments: dict[str, Any],
+    ) -> GetPromptResult:
+        return GetPromptResult(
+            description=name,
+            messages=[
+                PromptMessage(
+                    role="user",
+                    content=TextContent(
+                        type="text",
+                        text=json.dumps(arguments, sort_keys=True),
+                    ),
+                )
+            ],
+        )
+
+
+def create_pagination_server(*, page_size: int = 2):
+    resources = PaginationResourcesManager()
+    tools = PaginationToolsManager()
+    prompts = PaginationPromptsManager()
+    server = create_doris_mcp_server(
+        resources_manager=resources,
+        tools_manager=tools,
+        prompts_manager=prompts,
+        name="doris-mcp-pagination-test",
+        version=__version__,
+        logger=logging.getLogger(__name__),
+        list_page_size=page_size,
+    )
+    return server, PaginationManagers(
+        resources=resources,
+        tools=tools,
+        prompts=prompts,
+    )
diff --git a/test/protocol/pagination_stdio_server.py 
b/test/protocol/pagination_stdio_server.py
new file mode 100644
index 0000000..142a873
--- /dev/null
+++ b/test/protocol/pagination_stdio_server.py
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""True subprocess stdio fixture for MCP list pagination."""
+
+from __future__ import annotations
+
+import asyncio
+
+from mcp.server.stdio import stdio_server
+
+from test.protocol.pagination_fixture import create_pagination_server
+
+
+async def main() -> None:
+    server, _ = create_pagination_server(page_size=2)
+    async with stdio_server() as (read_stream, write_stream):
+        await server.run(
+            read_stream,
+            write_stream,
+            server.create_initialization_options(),
+        )
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/test/protocol/test_mcp_v2_protocol.py 
b/test/protocol/test_mcp_v2_protocol.py
index c0248da..30ee3f3 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -48,6 +48,16 @@ from doris_mcp_server.protocol import (
 from test.protocol.stdio_capability_server import OneToolManager as 
ProfileToolManager
 
 REQUIRED_EXTENSION = "io.apache.doris/read"
+PROFILE_TOOL_NAMES = sorted(
+    [
+        "echo",
+        "get_sql_profile",
+        "monitor_data_freshness",
+        "analyze_data_access_patterns",
+        "analyze_columns",
+        "get_monitoring_metrics",
+    ]
+)
 
 
 def test_transport_security_accepts_explicit_deployment_allowlists():
@@ -996,14 +1006,9 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
         stdio_client(server_params),
         extensions=[advertise(REQUIRED_EXTENSION)],
     ) as capable:
-        assert [tool.name for tool in (await capable.list_tools()).tools] == [
-            "echo",
-            "get_sql_profile",
-            "monitor_data_freshness",
-            "analyze_data_access_patterns",
-            "analyze_columns",
-            "get_monitoring_metrics",
-        ]
+        assert [
+            tool.name for tool in (await capable.list_tools()).tools
+        ] == PROFILE_TOOL_NAMES
         secret = "stdio-secret-sec-016"
         error_result = await capable.call_tool(
             "echo",
@@ -1024,14 +1029,9 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
         assert error_result.structured_content["token"] == "[REDACTED]"
 
     async with Client(stdio_client(server_params), mode="legacy") as legacy:
-        assert [tool.name for tool in (await legacy.list_tools()).tools] == [
-            "echo",
-            "get_sql_profile",
-            "monitor_data_freshness",
-            "analyze_data_access_patterns",
-            "analyze_columns",
-            "get_monitoring_metrics",
-        ]
+        assert [
+            tool.name for tool in (await legacy.list_tools()).tools
+        ] == PROFILE_TOOL_NAMES
         legacy_error = await legacy.read_resource("doris://table/missing")
         assert (
             json.loads(legacy_error.contents[0].text)["error_code"]
diff --git a/test/protocol/test_multiworker_config.py 
b/test/protocol/test_multiworker_config.py
index 495f55a..9c4d6ca 100644
--- a/test/protocol/test_multiworker_config.py
+++ b/test/protocol/test_multiworker_config.py
@@ -27,6 +27,22 @@ def 
test_legacy_http_adapter_is_default_off_and_requires_explicit_env(monkeypatc
     assert enabled.to_dict()["enable_legacy_http_adapter"] is True
 
 
+def test_mcp_list_page_size_is_configurable_and_bounded(monkeypatch):
+    monkeypatch.delenv("MCP_LIST_PAGE_SIZE", raising=False)
+    assert DorisConfig.from_env().mcp_list_page_size == 100
+
+    monkeypatch.setenv("MCP_LIST_PAGE_SIZE", "17")
+    configured = DorisConfig.from_env()
+    assert configured.mcp_list_page_size == 17
+    assert configured.to_dict()["mcp_list_page_size"] == 17
+    assert configured.validate() == []
+
+    configured.mcp_list_page_size = 0
+    assert "MCP list page size must be in the range 1-1000" in 
configured.validate()
+    configured.mcp_list_page_size = 1001
+    assert "MCP list page size must be in the range 1-1000" in 
configured.validate()
+
+
 def test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
     config = DorisConfig()
     config.database.host = "127.0.0.1"
@@ -38,6 +54,7 @@ def 
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
     config.mcp_allowed_hosts = ["mcp.example.test", "mcp.example.test:*"]
     config.mcp_allowed_origins = ["https://client.example.test";]
     config.enable_legacy_http_adapter = True
+    config.mcp_list_page_size = 17
 
     worker_env = _multiworker_environment(
         config,
@@ -64,6 +81,7 @@ def 
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
     ]
     assert child_config.mcp_allowed_origins == ["https://client.example.test";]
     assert child_config.enable_legacy_http_adapter is True
+    assert child_config.mcp_list_page_size == 17
     assert child_config.server_name == "doris-mcp-server"
     assert child_config.server_version == __version__
     assert child_config.transport == "http"
diff --git a/test/protocol/test_protocol_pagination.py 
b/test/protocol/test_protocol_pagination.py
new file mode 100644
index 0000000..f0ef5d2
--- /dev/null
+++ b/test/protocol/test_protocol_pagination.py
@@ -0,0 +1,274 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Stable, permission-bound pagination for MCP list operations."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import httpx2
+import pytest
+from mcp import Client, MCPError, StdioServerParameters
+from mcp.client.stdio import stdio_client
+from mcp.types import (
+    INVALID_PARAMS,
+    LATEST_PROTOCOL_VERSION,
+    PaginatedRequestParams,
+    Tool,
+)
+
+from doris_mcp_server.http_transport import DorisMCPHTTPTransport
+from doris_mcp_server.utils.security import (
+    AuthContext,
+    reset_auth_context,
+    set_current_auth_context,
+)
+from test.protocol.pagination_fixture import (
+    PROMPT_NAMES,
+    RESOURCE_URIS,
+    TOOL_NAMES,
+    create_pagination_server,
+)
+
+
+async def _collect_pages(
+    list_method,
+    *,
+    result_field: str,
+    item_key,
+) -> tuple[list[str], int]:
+    cursor = None
+    seen_cursors: set[str] = set()
+    identifiers: list[str] = []
+    page_count = 0
+
+    while True:
+        result = await list_method(cursor=cursor, cache_mode="bypass")
+        page_count += 1
+        page = getattr(result, result_field)
+        assert 0 < len(page) <= 2
+        identifiers.extend(item_key(item) for item in page)
+
+        cursor = result.next_cursor
+        if cursor is None:
+            break
+        assert cursor not in seen_cursors
+        seen_cursors.add(cursor)
+
+    return identifiers, page_count
+
+
+async def _assert_all_lists_are_complete(client: Client) -> None:
+    resource_uris, resource_pages = await _collect_pages(
+        client.list_resources,
+        result_field="resources",
+        item_key=lambda resource: str(resource.uri),
+    )
+    tool_names, tool_pages = await _collect_pages(
+        client.list_tools,
+        result_field="tools",
+        item_key=lambda tool: tool.name,
+    )
+    prompt_names, prompt_pages = await _collect_pages(
+        client.list_prompts,
+        result_field="prompts",
+        item_key=lambda prompt: prompt.name,
+    )
+
+    assert resource_uris == RESOURCE_URIS
+    assert tool_names == TOOL_NAMES
+    assert prompt_names == PROMPT_NAMES
+    assert len(resource_uris) == len(set(resource_uris))
+    assert len(tool_names) == len(set(tool_names))
+    assert len(prompt_names) == len(set(prompt_names))
+    assert (resource_pages, tool_pages, prompt_pages) == (3, 3, 3)
+
+
[email protected]("mode", ["modern", "legacy"])
[email protected]
+async def test_all_list_operations_page_without_duplicates_or_loss(mode: str):
+    server, _ = create_pagination_server()
+    client_context = (
+        Client(server) if mode == "modern" else Client(server, mode="legacy")
+    )
+
+    async with client_context as client:
+        await _assert_all_lists_are_complete(client)
+
+
[email protected]
+async def test_streamable_http_carries_opaque_cursor_across_all_list_methods():
+    server, _ = create_pagination_server()
+    app = DorisMCPHTTPTransport(app=server, security_settings=None)
+
+    async with (
+        app.run(),
+        httpx2.ASGITransport(app.handle_request) as transport,
+        httpx2.AsyncClient(
+            transport=transport,
+            base_url="http://127.0.0.1:3000";,
+        ) as client,
+    ):
+        for method, result_field, item_key, expected in (
+            (
+                "resources/list",
+                "resources",
+                lambda item: item["uri"],
+                RESOURCE_URIS,
+            ),
+            ("tools/list", "tools", lambda item: item["name"], TOOL_NAMES),
+            ("prompts/list", "prompts", lambda item: item["name"], 
PROMPT_NAMES),
+        ):
+            cursor = None
+            identifiers: list[str] = []
+            while True:
+                params: dict[str, Any] = {
+                    "_meta": {
+                        "io.modelcontextprotocol/protocolVersion": 
"2026-07-28",
+                        "io.modelcontextprotocol/clientCapabilities": {},
+                        "io.modelcontextprotocol/clientInfo": {
+                            "name": "pagination-http-test",
+                            "version": "1.0.0",
+                        },
+                    }
+                }
+                if cursor is not None:
+                    params["cursor"] = cursor
+                response = await client.post(
+                    "/mcp",
+                    json={
+                        "jsonrpc": "2.0",
+                        "id": len(identifiers) + 1,
+                        "method": method,
+                        "params": params,
+                    },
+                    headers={
+                        "Accept": "application/json, text/event-stream",
+                        "Content-Type": "application/json",
+                        "Mcp-Protocol-Version": "2026-07-28",
+                        "Mcp-Method": method,
+                    },
+                )
+                assert response.status_code == 200
+                assert "mcp-session-id" not in response.headers
+                result = response.json()["result"]
+                identifiers.extend(item_key(item) for item in 
result[result_field])
+                cursor = result.get("nextCursor")
+                if cursor is None:
+                    break
+
+            assert identifiers == expected
+
+
[email protected]
+async def test_true_subprocess_stdio_pages_all_list_methods():
+    project_root = Path(__file__).resolve().parents[2]
+    server_params = StdioServerParameters(
+        command=sys.executable,
+        args=["-m", "test.protocol.pagination_stdio_server"],
+        cwd=project_root,
+    )
+
+    async with Client(stdio_client(server_params)) as client:
+        await _assert_all_lists_are_complete(client)
+
+
[email protected]
+async def test_cursor_rejects_wrong_collection_malformed_and_stale_snapshots():
+    server, managers = create_pagination_server()
+
+    async with Client(server) as client:
+        first_tools = await client.list_tools(cache_mode="bypass")
+        assert first_tools.next_cursor is not None
+
+        with pytest.raises(MCPError) as wrong_collection:
+            await client.list_resources(
+                cursor=first_tools.next_cursor,
+                cache_mode="bypass",
+            )
+        assert wrong_collection.value.code == INVALID_PARAMS
+        assert wrong_collection.value.data == {"cursorError": 
"wrong_collection"}
+
+        with pytest.raises(MCPError) as malformed:
+            await client.list_tools(
+                cursor="not-a-valid-pagination-cursor",
+                cache_mode="bypass",
+            )
+        assert malformed.value.code == INVALID_PARAMS
+        assert malformed.value.data == {"cursorError": "invalid"}
+
+        managers.tools.tools.append(
+            Tool(
+                name="foxtrot",
+                description="new item invalidates the snapshot",
+                input_schema={"type": "object", "properties": {}},
+            )
+        )
+        with pytest.raises(MCPError) as stale:
+            await client.list_tools(
+                cursor=first_tools.next_cursor,
+                cache_mode="bypass",
+            )
+        assert stale.value.code == INVALID_PARAMS
+        assert stale.value.data == {"cursorError": "stale"}
+
+
[email protected]
+async def test_cursor_is_bound_to_the_authorization_context():
+    server, _ = create_pagination_server(page_size=1)
+    entry = server.get_request_handler("tools/list")
+    assert entry is not None
+    context = SimpleNamespace(protocol_version=LATEST_PROTOCOL_VERSION)
+
+    first_context = AuthContext(
+        token_id="token-a",
+        user_id="user-a",
+        permissions=["tool:list"],
+        roles=["analyst"],
+        auth_method="token",
+    )
+    token = set_current_auth_context(first_context)
+    try:
+        first_page = await entry.handler(context, PaginatedRequestParams())
+    finally:
+        reset_auth_context(token)
+    assert first_page.next_cursor is not None
+
+    second_context = AuthContext(
+        token_id="token-b",
+        user_id="user-b",
+        permissions=["tool:list"],
+        roles=["analyst"],
+        auth_method="token",
+    )
+    token = set_current_auth_context(second_context)
+    try:
+        with pytest.raises(MCPError) as changed_context:
+            await entry.handler(
+                context,
+                PaginatedRequestParams(cursor=first_page.next_cursor),
+            )
+    finally:
+        reset_auth_context(token)
+
+    assert changed_context.value.code == INVALID_PARAMS
+    assert changed_context.value.data == {
+        "cursorError": "authorization_context_changed"
+    }
diff --git a/test/tools/test_resources_manager_cache.py 
b/test/tools/test_resources_manager_cache.py
index 4dcff8c..87c5192 100644
--- a/test/tools/test_resources_manager_cache.py
+++ b/test/tools/test_resources_manager_cache.py
@@ -196,14 +196,53 @@ async def test_metadata_cache_disabled_by_default():
 
 
 @pytest.mark.asyncio
-async def test_resources_manager_does_not_reuse_global_metadata_cache():
+async def test_resources_manager_reuses_identity_scoped_metadata_cache():
     connection_manager = FakeConnectionManager()
     manager = DorisResourcesManager(connection_manager)
 
     first = await manager._get_table_metadata()
     second = await manager._get_table_metadata()
 
-    assert manager.metadata_cache.enabled is False
+    assert manager.metadata_cache.enabled is True
+    assert [table.name for table in first] == ["orders_1"]
+    assert [table.name for table in second] == ["orders_1"]
+    assert connection_manager.acquires == 1
+    assert connection_manager.releases == 1
+
+
[email protected]
+async def test_resources_manager_does_not_share_metadata_across_identities():
+    connection_manager = FakeConnectionManager()
+    manager = DorisResourcesManager(connection_manager)
+
+    first_token = set_current_auth_context(
+        AuthContext(
+            token_id="token-a",
+            user_id="user-a",
+            roles=["reader"],
+            permissions=["resource:list"],
+            auth_method="token",
+        )
+    )
+    try:
+        first = await manager._get_table_metadata()
+    finally:
+        reset_auth_context(first_token)
+
+    second_token = set_current_auth_context(
+        AuthContext(
+            token_id="token-b",
+            user_id="user-b",
+            roles=["reader"],
+            permissions=["resource:list"],
+            auth_method="token",
+        )
+    )
+    try:
+        second = await manager._get_table_metadata()
+    finally:
+        reset_auth_context(second_token)
+
     assert [table.name for table in first] == ["orders_1"]
     assert [table.name for table in second] == ["orders_2"]
     assert connection_manager.acquires == 2


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to