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 8831956  feat: secure explicit state handles (#146)
8831956 is described below

commit 88319560e9d982bff5f5fdec46d4596ec255348d
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 12:30:25 2026 +0800

    feat: secure explicit state handles (#146)
---
 .env.example                                  |   8 +
 CHANGELOG.md                                  |   2 +
 README.md                                     |  29 ++-
 docs/decisions/0002-explicit-state-handles.md |  48 ++++
 doris_mcp_server/main.py                      |   4 +
 doris_mcp_server/multiworker_app.py           |   2 +
 doris_mcp_server/pagination.py                | 157 ++++---------
 doris_mcp_server/protocol.py                  |  20 ++
 doris_mcp_server/state_handles.py             | 302 ++++++++++++++++++++++++++
 doris_mcp_server/utils/config.py              |  26 +++
 test/protocol/pagination_fixture.py           |   9 +-
 test/protocol/test_multiworker_config.py      |  34 +++
 test/protocol/test_protocol_pagination.py     |  67 +++++-
 test/protocol/test_state_handles.py           | 246 +++++++++++++++++++++
 14 files changed, 837 insertions(+), 117 deletions(-)

diff --git a/.env.example b/.env.example
index 6c11165..b878b74 100644
--- a/.env.example
+++ b/.env.example
@@ -461,6 +461,14 @@ ENABLE_LEGACY_HTTP_ADAPTER=false
 # Maximum resources, tools, or prompts returned in one list page (1-1000).
 MCP_LIST_PAGE_SIZE=100
 
+# Explicit cross-call state handles are principal/resource/scope bound, signed,
+# and expire after the configured lifetime (1-3600 seconds). A launch-local
+# secret is generated automatically. Independently launched replicas behind a
+# load balancer must share one generated value of at least 32 bytes.
+# python -c 'import secrets; print(secrets.token_urlsafe(32))'
+# MCP_STATE_HANDLE_SECRET=<paste-generated-value-here>
+MCP_STATE_HANDLE_TTL_SECONDS=300
+
 # Temporary files directory
 TEMP_FILES_DIR=tmp
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc4da6c..0ee99c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,8 @@ under **Unreleased** until a new version is selected and 
published.
   errors, and deterministic product identity.
 - Permission-bound cursor pagination for resources, tools, and prompts on
   Streamable HTTP and stdio.
+- HMAC-authenticated explicit state handles with principal, scope, resource,
+  expiry, and shared-worker key binding instead of protocol-session state.
 - Real Doris process tests covering Streamable HTTP and stdio.
 
 ### Changed
diff --git a/README.md b/README.md
index df0b48b..cec24d3 100644
--- a/README.md
+++ b/README.md
@@ -210,6 +210,10 @@ export ENABLE_LEGACY_HTTP_ADAPTER=false
 
 # Bound each resources/list, tools/list, and prompts/list response.
 export MCP_LIST_PAGE_SIZE=100
+# A launch-local key is generated automatically. Configure one shared
+# high-entropy value when independently launched replicas share traffic.
+export MCP_STATE_HANDLE_SECRET="$(python -c 'import secrets; 
print(secrets.token_urlsafe(32))')"
+export MCP_STATE_HANDLE_TTL_SECONDS=300
 
 # Token Management Interface (Security-Critical)
 export TOKEN_ADMIN="$(python -c 'import secrets; 
print(secrets.token_urlsafe(32))')"
@@ -288,6 +292,10 @@ cp .env.example .env
         modern traffic always uses `POST /mcp`
     *   `MCP_LIST_PAGE_SIZE`: Maximum resources, tools, or prompts returned
         per protocol page (default: 100; range: 1-1000)
+    *   `MCP_STATE_HANDLE_SECRET`: Optional shared high-entropy key (at least
+        32 bytes) used to authenticate explicit cross-call state handles
+    *   `MCP_STATE_HANDLE_TTL_SECONDS`: Lifetime of an explicit state handle
+        (default: 300 seconds; range: 1-3600)
 *   **Authentication Configuration (Enhanced in v0.6.0)**:
     *   `ENABLE_TOKEN_AUTH`: Enable token-based authentication (default: false)
     *   `ENABLE_JWT_AUTH`: Enable JWT authentication (default: false)
@@ -527,11 +535,22 @@ 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.
+A cursor is an HMAC-authenticated explicit state handle bound to its list type,
+scope, resource, current visible-list snapshot, authorization context, and
+expiry. It does not depend on an MCP protocol session, transport connection, or
+worker-local memory. Reusing it for another list, after visibility changes,
+after expiry, after modification, 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.
+
+The server generates a launch-local handle secret by default and passes it to
+all workers created by the same CLI process. Set one shared
+`MCP_STATE_HANDLE_SECRET` on independently launched replicas when a load
+balancer can route consecutive pages to different instances. Handle payloads
+contain bounded continuation metadata and are signed rather than encrypted;
+credentials, SQL text, and query results must never be placed in them. See
+[ADR 0002](docs/decisions/0002-explicit-state-handles.md).
 
 List failures are never represented as successful empty collections. A Doris
 metadata outage returns `List backend unavailable`; a Doris metadata permission
diff --git a/docs/decisions/0002-explicit-state-handles.md 
b/docs/decisions/0002-explicit-state-handles.md
new file mode 100644
index 0000000..37f88d3
--- /dev/null
+++ b/docs/decisions/0002-explicit-state-handles.md
@@ -0,0 +1,48 @@
+# ADR 0002: Cross-call state uses explicit principal-bound handles
+
+## Status
+
+Accepted.
+
+## Context
+
+MCP `2026-07-28` has no protocol session or `Mcp-Session-Id`. A server cannot
+attach continuation state to a transport connection or assume that a later
+request reaches the same worker.
+
+The current Doris MCP Server needs cross-call state only for list pagination.
+The previous cursor encoded its list position, visible snapshot, and
+authorization fingerprint as unsigned Base64URL JSON. It was permission-bound,
+but it had no expiry and a client could alter its fields.
+
+## Decision
+
+Cross-call state is carried by an explicit ordinary request/response value:
+
+- the server returns a state handle, currently as `nextCursor`;
+- the client passes that handle back as an ordinary `cursor` parameter;
+- the handle is HMAC authenticated and binds its kind, scope, resource,
+  authorization fingerprint, expiry, and bounded JSON state;
+- the authorization fingerprint excludes protocol session identifiers, client
+  address, raw bearer values, and request timing;
+- the default lifetime is 300 seconds and the hard maximum is 3600 seconds;
+- expired, modified, cross-principal, cross-scope, and cross-resource handles
+  fail as `Invalid Params`;
+- the handle secret is generated per server launch unless
+  `MCP_STATE_HANDLE_SECRET` supplies at least 32 bytes;
+- the CLI parent passes one resolved secret to every local Uvicorn worker.
+  Independently launched replicas must be configured with the same secret if a
+  load balancer may route successive calls to different replicas.
+
+Handle payloads are signed, not encrypted. Only bounded continuation metadata
+may be placed in them. Secrets, SQL text, query results, and credentials must
+not be stored in a handle.
+
+## Consequences
+
+List pagination works across modern Streamable HTTP, legacy migration traffic,
+stdio, and local workers without sticky protocol sessions. A server restart or
+secret rotation invalidates outstanding handles, so clients restart the list
+from its first page. Future cross-call workflows must reuse this boundary or
+introduce a shared transactional state backend; they must not use worker-local
+memory or revive protocol sessions.
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index f70d62b..6337fbb 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -84,6 +84,8 @@ def _multiworker_environment(
             config.enable_legacy_http_adapter
         ).lower(),
         "MCP_LIST_PAGE_SIZE": str(config.mcp_list_page_size),
+        "MCP_STATE_HANDLE_SECRET": config.mcp_state_handle_secret,
+        "MCP_STATE_HANDLE_TTL_SECONDS": 
str(config.mcp_state_handle_ttl_seconds),
         "SERVER_NAME": config.server_name,
         "TRANSPORT": "http",
         "WORKERS": str(workers),
@@ -136,6 +138,8 @@ class DorisServer:
             version=config.server_version,
             logger=self.logger,
             list_page_size=config.mcp_list_page_size,
+            state_handle_secret=config.mcp_state_handle_secret,
+            state_handle_ttl_seconds=config.mcp_state_handle_ttl_seconds,
         )
 
     async def start_stdio(self) -> None:
diff --git a/doris_mcp_server/multiworker_app.py 
b/doris_mcp_server/multiworker_app.py
index 38dc6e7..d6d7807 100644
--- a/doris_mcp_server/multiworker_app.py
+++ b/doris_mcp_server/multiworker_app.py
@@ -164,6 +164,8 @@ async def initialize_worker() -> None:
             version=config.server_version,
             logger=logger,
             list_page_size=config.mcp_list_page_size,
+            state_handle_secret=config.mcp_state_handle_secret,
+            state_handle_ttl_seconds=config.mcp_state_handle_ttl_seconds,
         )
 
         # Create the exact modern/legacy HTTP boundary for this worker.
diff --git a/doris_mcp_server/pagination.py b/doris_mcp_server/pagination.py
index 11c4abc..98c3fbb 100644
--- a/doris_mcp_server/pagination.py
+++ b/doris_mcp_server/pagination.py
@@ -14,32 +14,22 @@
 # 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."""
+"""Explicit, permission-bound continuation handles 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 typing import Generic, Literal, TypeVar
 
+from .state_handles import StateHandleCodec, StateHandleError
 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")
@@ -61,91 +51,21 @@ class PaginationPage(Generic[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 _handle_scope(collection: CursorCollection) -> str:
+    return f"{collection}:list"
 
 
-def _encode_cursor(payload: dict[str, Any]) -> str:
-    return (
-        
base64.urlsafe_b64encode(_canonical_json(payload)).decode("ascii").rstrip("=")
-    )
+def _handle_resource(collection: CursorCollection) -> str:
+    return f"mcp://{collection}"
 
 
-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 _snapshot_digest(identifiers: list[str]) -> str:
+    payload = json.dumps(
+        identifiers,
+        ensure_ascii=False,
+        separators=(",", ":"),
+    ).encode("utf-8")
+    return sha256(payload).hexdigest()
 
 
 def paginate(
@@ -156,8 +76,9 @@ def paginate(
     page_size: int,
     identifier: Callable[[T], str],
     auth_context: AuthContext | None,
+    handle_codec: StateHandleCodec,
 ) -> PaginationPage[T]:
-    """Return a deterministic keyset page or reject an unsafe continuation."""
+    """Return a deterministic page or reject an unsafe explicit handle."""
     if not 1 <= page_size <= MAX_LIST_PAGE_SIZE:
         raise ValueError(f"page_size must be in the range 
1-{MAX_LIST_PAGE_SIZE}")
 
@@ -166,20 +87,35 @@ def paginate(
     if len(identifiers) != len(set(identifiers)):
         raise RuntimeError(f"{collection} list contains duplicate identifiers")
 
-    snapshot = _digest(identifiers)
-    authorization = _authorization_fingerprint(auth_context)
+    snapshot = _snapshot_digest(identifiers)
     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):
+        try:
+            claims = handle_codec.resolve(
+                cursor,
+                expected_kind="list-page",
+                expected_scope=_handle_scope(collection),
+                expected_resource=_handle_resource(collection),
+                auth_context=auth_context,
+            )
+        except StateHandleError as exc:
+            reason = (
+                "wrong_collection"
+                if exc.reason in {"wrong_resource", "wrong_scope"}
+                else exc.reason
+            )
+            raise PaginationCursorError(reason) from exc
+        if set(claims.state) != {"after", "snapshot"}:
+            raise PaginationCursorError("invalid")
+        after = claims.state.get("after")
+        claimed_snapshot = claims.state.get("snapshot")
+        if not isinstance(after, str) or not isinstance(claimed_snapshot, str):
+            raise PaginationCursorError("invalid")
+        if not compare_digest(claimed_snapshot, snapshot):
             raise PaginationCursorError("stale")
         try:
-            start = identifiers.index(payload["after"]) + 1
+            start = identifiers.index(after) + 1
         except ValueError as exc:
             raise PaginationCursorError("stale") from exc
         if start >= len(ordered):
@@ -189,14 +125,15 @@ def paginate(
     end = start + len(page_items)
     next_cursor = None
     if page_items and end < len(ordered):
-        next_cursor = _encode_cursor(
-            {
-                "version": _CURSOR_VERSION,
-                "collection": collection,
+        next_cursor = handle_codec.issue(
+            kind="list-page",
+            scope=_handle_scope(collection),
+            resource=_handle_resource(collection),
+            state={
                 "after": identifiers[end - 1],
                 "snapshot": snapshot,
-                "authorization": authorization,
-            }
+            },
+            auth_context=auth_context,
         )
 
     return PaginationPage(items=page_items, next_cursor=next_cursor)
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 1b576fe..1972fec 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 
 import json
 import logging
+import secrets
 from collections.abc import Callable, Iterable, Mapping, Sequence
 from typing import Any, Protocol, TypeVar
 
@@ -68,6 +69,10 @@ from .schema_validation import (
     ToolOutputValidationError,
     ToolSchemaGuard,
 )
+from .state_handles import (
+    DEFAULT_STATE_HANDLE_TTL_SECONDS,
+    StateHandleCodec,
+)
 from .utils.redaction import (
     redact_error_payload,
     redact_sensitive_text,
@@ -243,6 +248,7 @@ def _paginate_list_or_raise(
     cursor: str | None,
     page_size: int,
     identifier: Callable[[_ListItemT], str],
+    handle_codec: StateHandleCodec,
 ) -> PaginationPage[_ListItemT]:
     try:
         return paginate(
@@ -252,6 +258,7 @@ def _paginate_list_or_raise(
             page_size=page_size,
             identifier=identifier,
             auth_context=get_current_auth_context(),
+            handle_codec=handle_codec,
         )
     except PaginationCursorError as exc:
         raise MCPError(
@@ -288,6 +295,8 @@ def create_doris_mcp_server(
     version: str,
     logger: logging.Logger,
     list_page_size: int = DEFAULT_LIST_PAGE_SIZE,
+    state_handle_secret: str | bytes | None = None,
+    state_handle_ttl_seconds: int = DEFAULT_STATE_HANDLE_TTL_SECONDS,
     schema_limits: SchemaLimits = DEFAULT_SCHEMA_LIMITS,
     required_client_capabilities: Mapping[str, ClientCapabilities] | None = 
None,
     required_tool_capabilities: Mapping[str, ClientCapabilities] | None = None,
@@ -295,6 +304,14 @@ def create_doris_mcp_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}")
+    handle_codec = StateHandleCodec(
+        (
+            state_handle_secret
+            if state_handle_secret is not None
+            else secrets.token_urlsafe(32)
+        ),
+        default_ttl_seconds=state_handle_ttl_seconds,
+    )
     schema_guard = ToolSchemaGuard(schema_limits)
 
     async def list_resources(
@@ -311,6 +328,7 @@ def create_doris_mcp_server(
                 cursor=params.cursor if params else None,
                 page_size=list_page_size,
                 identifier=lambda resource: str(resource.uri),
+                handle_codec=handle_codec,
             )
         except (MCPError, OperationAuthorizationError):
             raise
@@ -371,6 +389,7 @@ def create_doris_mcp_server(
                 cursor=params.cursor if params else None,
                 page_size=list_page_size,
                 identifier=lambda tool: tool.name,
+                handle_codec=handle_codec,
             )
         except (MCPError, OperationAuthorizationError):
             raise
@@ -457,6 +476,7 @@ def create_doris_mcp_server(
                 cursor=params.cursor if params else None,
                 page_size=list_page_size,
                 identifier=lambda prompt: prompt.name,
+                handle_codec=handle_codec,
             )
         except (MCPError, OperationAuthorizationError):
             raise
diff --git a/doris_mcp_server/state_handles.py 
b/doris_mcp_server/state_handles.py
new file mode 100644
index 0000000..fd375fb
--- /dev/null
+++ b/doris_mcp_server/state_handles.py
@@ -0,0 +1,302 @@
+# 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.
+"""Tamper-evident, principal-bound state handles for stateless MCP calls."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+import secrets
+import time
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass
+from hashlib import sha256
+from hmac import compare_digest
+from hmac import new as new_hmac
+from typing import Any
+
+from .utils.security import AuthContext
+
+DEFAULT_STATE_HANDLE_TTL_SECONDS = 300
+MAX_STATE_HANDLE_TTL_SECONDS = 3600
+MIN_STATE_HANDLE_SECRET_BYTES = 32
+
+_HANDLE_PREFIX = "mcp-h1"
+_HANDLE_VERSION = 1
+_MAX_HANDLE_LENGTH = 4096
+_MAX_PAYLOAD_BYTES = 2048
+_MAX_CLAIM_LENGTH = 256
+_HANDLE_FIELDS = {
+    "version",
+    "handleId",
+    "kind",
+    "scope",
+    "resource",
+    "principal",
+    "expiresAt",
+    "state",
+}
+
+
+class StateHandleError(ValueError):
+    """A state handle is malformed, expired, or outside the caller boundary."""
+
+    def __init__(self, reason: str) -> None:
+        super().__init__(reason)
+        self.reason = reason
+
+
+@dataclass(frozen=True)
+class StateHandleClaims:
+    """Verified, non-identity claims recovered from an explicit state 
handle."""
+
+    handle_id: str
+    kind: str
+    scope: str
+    resource: str
+    expires_at: int
+    state: dict[str, Any]
+
+
+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:
+    """Hash the stable authorization boundary without protocol-session 
state."""
+    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_base64url(value: bytes) -> str:
+    return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
+
+
+def _decode_base64url(value: str) -> bytes:
+    try:
+        padding = "=" * (-len(value) % 4)
+        return base64.b64decode(
+            (value + padding).encode("ascii"),
+            altchars=b"-_",
+            validate=True,
+        )
+    except (UnicodeEncodeError, binascii.Error) as exc:
+        raise StateHandleError("invalid") from exc
+
+
+def _validate_claim(name: str, value: str) -> None:
+    if not value or len(value) > _MAX_CLAIM_LENGTH:
+        raise ValueError(f"{name} must be 1-{_MAX_CLAIM_LENGTH} characters")
+
+
+class StateHandleCodec:
+    """Issue and verify explicit state handles shared by MCP transports."""
+
+    def __init__(
+        self,
+        secret: str | bytes,
+        *,
+        default_ttl_seconds: int = DEFAULT_STATE_HANDLE_TTL_SECONDS,
+        clock: Callable[[], float] = time.time,
+        nonce_factory: Callable[[], str] = lambda: secrets.token_urlsafe(18),
+    ) -> None:
+        secret_bytes = secret.encode("utf-8") if isinstance(secret, str) else 
secret
+        if len(secret_bytes) < MIN_STATE_HANDLE_SECRET_BYTES:
+            raise ValueError(
+                "state handle secret must contain at least "
+                f"{MIN_STATE_HANDLE_SECRET_BYTES} bytes"
+            )
+        if not 1 <= default_ttl_seconds <= MAX_STATE_HANDLE_TTL_SECONDS:
+            raise ValueError(
+                "state handle TTL must be in the range "
+                f"1-{MAX_STATE_HANDLE_TTL_SECONDS} seconds"
+            )
+        self._secret = secret_bytes
+        self._default_ttl_seconds = default_ttl_seconds
+        self._clock = clock
+        self._nonce_factory = nonce_factory
+
+    def issue(
+        self,
+        *,
+        kind: str,
+        scope: str,
+        resource: str,
+        state: Mapping[str, Any],
+        auth_context: AuthContext | None,
+        ttl_seconds: int | None = None,
+    ) -> str:
+        """Return an explicit handle for state needed by a later MCP call."""
+        _validate_claim("kind", kind)
+        _validate_claim("scope", scope)
+        _validate_claim("resource", resource)
+        ttl = self._default_ttl_seconds if ttl_seconds is None else ttl_seconds
+        if isinstance(ttl, bool) or not 1 <= ttl <= 
MAX_STATE_HANDLE_TTL_SECONDS:
+            raise ValueError(
+                "state handle TTL must be in the range "
+                f"1-{MAX_STATE_HANDLE_TTL_SECONDS} seconds"
+            )
+
+        now = int(self._clock())
+        handle_id = self._nonce_factory()
+        _validate_claim("handle_id", handle_id)
+        payload = {
+            "version": _HANDLE_VERSION,
+            "handleId": handle_id,
+            "kind": kind,
+            "scope": scope,
+            "resource": resource,
+            "principal": authorization_fingerprint(auth_context),
+            "expiresAt": now + ttl,
+            "state": dict(state),
+        }
+        payload_bytes = _canonical_json(payload)
+        if len(payload_bytes) > _MAX_PAYLOAD_BYTES:
+            raise ValueError(f"state handle payload exceeds 
{_MAX_PAYLOAD_BYTES} bytes")
+
+        encoded_payload = _encode_base64url(payload_bytes)
+        signature = new_hmac(
+            self._secret,
+            encoded_payload.encode("ascii"),
+            sha256,
+        ).digest()
+        handle = ".".join(
+            (_HANDLE_PREFIX, encoded_payload, _encode_base64url(signature))
+        )
+        if len(handle) > _MAX_HANDLE_LENGTH:
+            raise ValueError(f"state handle exceeds {_MAX_HANDLE_LENGTH} 
characters")
+        return handle
+
+    def resolve(
+        self,
+        handle: str,
+        *,
+        expected_kind: str,
+        expected_scope: str,
+        expected_resource: str,
+        auth_context: AuthContext | None,
+    ) -> StateHandleClaims:
+        """Verify a handle and return state only inside its original 
boundary."""
+        if not handle or len(handle) > _MAX_HANDLE_LENGTH:
+            raise StateHandleError("invalid")
+        parts = handle.split(".")
+        if len(parts) != 3 or parts[0] != _HANDLE_PREFIX:
+            raise StateHandleError("invalid")
+
+        encoded_payload, encoded_signature = parts[1:]
+        supplied_signature = _decode_base64url(encoded_signature)
+        expected_signature = new_hmac(
+            self._secret,
+            encoded_payload.encode("ascii"),
+            sha256,
+        ).digest()
+        if not compare_digest(supplied_signature, expected_signature):
+            raise StateHandleError("invalid")
+
+        payload_bytes = _decode_base64url(encoded_payload)
+        if len(payload_bytes) > _MAX_PAYLOAD_BYTES:
+            raise StateHandleError("invalid")
+        try:
+            payload = json.loads(payload_bytes.decode("utf-8"))
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise StateHandleError("invalid") from exc
+
+        if not isinstance(payload, dict) or set(payload) != _HANDLE_FIELDS:
+            raise StateHandleError("invalid")
+        if payload.get("version") != _HANDLE_VERSION:
+            raise StateHandleError("invalid")
+        if not all(
+            isinstance(payload.get(field), str)
+            for field in (
+                "handleId",
+                "kind",
+                "scope",
+                "resource",
+                "principal",
+            )
+        ):
+            raise StateHandleError("invalid")
+        expires_at = payload.get("expiresAt")
+        if isinstance(expires_at, bool) or not isinstance(expires_at, int):
+            raise StateHandleError("invalid")
+        state = payload.get("state")
+        if not isinstance(state, dict):
+            raise StateHandleError("invalid")
+        if expires_at <= int(self._clock()):
+            raise StateHandleError("expired")
+        if payload["kind"] != expected_kind:
+            raise StateHandleError("wrong_kind")
+        if payload["scope"] != expected_scope:
+            raise StateHandleError("wrong_scope")
+        if payload["resource"] != expected_resource:
+            raise StateHandleError("wrong_resource")
+        if not compare_digest(
+            payload["principal"],
+            authorization_fingerprint(auth_context),
+        ):
+            raise StateHandleError("authorization_context_changed")
+
+        return StateHandleClaims(
+            handle_id=payload["handleId"],
+            kind=payload["kind"],
+            scope=payload["scope"],
+            resource=payload["resource"],
+            expires_at=expires_at,
+            state=dict(state),
+        )
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 5772623..d41359d 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -24,6 +24,7 @@ import json
 import logging
 import multiprocessing
 import os
+import secrets
 from dataclasses import dataclass, field
 from ipaddress import ip_address
 from pathlib import Path
@@ -601,6 +602,11 @@ class DorisConfig:
     mcp_allowed_origins: list[str] = field(default_factory=list)
     enable_legacy_http_adapter: bool = False
     mcp_list_page_size: int = 100
+    mcp_state_handle_secret: str = field(
+        default_factory=lambda: secrets.token_urlsafe(32),
+        repr=False,
+    )
+    mcp_state_handle_ttl_seconds: int = 300
     transport: str = "stdio"
     workers: int = 1
 
@@ -1152,6 +1158,18 @@ class DorisConfig:
                 config.mcp_list_page_size,
             )
             _mark_source(config, "mcp_list_page_size", "env")
+        if "MCP_STATE_HANDLE_SECRET" in os.environ:
+            config.mcp_state_handle_secret = os.getenv(
+                "MCP_STATE_HANDLE_SECRET",
+                "",
+            )
+            _mark_source(config, "mcp_state_handle_secret", "env")
+        if "MCP_STATE_HANDLE_TTL_SECONDS" in os.environ:
+            config.mcp_state_handle_ttl_seconds = _env_int(
+                "MCP_STATE_HANDLE_TTL_SECONDS",
+                config.mcp_state_handle_ttl_seconds,
+            )
+            _mark_source(config, "mcp_state_handle_ttl_seconds", "env")
         config.temp_files_dir = os.getenv("TEMP_FILES_DIR", 
config.temp_files_dir)
 
         return config
@@ -1170,6 +1188,7 @@ class DorisConfig:
             "mcp_allowed_origins",
             "enable_legacy_http_adapter",
             "mcp_list_page_size",
+            "mcp_state_handle_ttl_seconds",
             "temp_files_dir",
             "transport",
             "workers",
@@ -1245,6 +1264,7 @@ class DorisConfig:
             "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,
+            "mcp_state_handle_ttl_seconds": self.mcp_state_handle_ttl_seconds,
             "temp_files_dir": self.temp_files_dir,
             "database": {
                 "host": self.database.host,
@@ -1442,6 +1462,12 @@ class DorisConfig:
         if not 1 <= self.mcp_list_page_size <= 1000:
             errors.append("MCP list page size must be in the range 1-1000")
 
+        if len(self.mcp_state_handle_secret.encode("utf-8")) < 32:
+            errors.append("MCP state handle secret must contain at least 32 
bytes")
+
+        if not 1 <= self.mcp_state_handle_ttl_seconds <= 3600:
+            errors.append("MCP state handle TTL must be in the range 1-3600 
seconds")
+
         # 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/protocol/pagination_fixture.py 
b/test/protocol/pagination_fixture.py
index 3aca503..6479d34 100644
--- a/test/protocol/pagination_fixture.py
+++ b/test/protocol/pagination_fixture.py
@@ -118,7 +118,12 @@ class PaginationPromptsManager:
         )
 
 
-def create_pagination_server(*, page_size: int = 2):
+def create_pagination_server(
+    *,
+    page_size: int = 2,
+    state_handle_secret: str | None = None,
+    state_handle_ttl_seconds: int = 300,
+):
     resources = PaginationResourcesManager()
     tools = PaginationToolsManager()
     prompts = PaginationPromptsManager()
@@ -130,6 +135,8 @@ def create_pagination_server(*, page_size: int = 2):
         version=__version__,
         logger=logging.getLogger(__name__),
         list_page_size=page_size,
+        state_handle_secret=state_handle_secret,
+        state_handle_ttl_seconds=state_handle_ttl_seconds,
     )
     return server, PaginationManagers(
         resources=resources,
diff --git a/test/protocol/test_multiworker_config.py 
b/test/protocol/test_multiworker_config.py
index 9c4d6ca..9d05131 100644
--- a/test/protocol/test_multiworker_config.py
+++ b/test/protocol/test_multiworker_config.py
@@ -43,6 +43,33 @@ def 
test_mcp_list_page_size_is_configurable_and_bounded(monkeypatch):
     assert "MCP list page size must be in the range 1-1000" in 
configured.validate()
 
 
+def 
test_state_handle_secret_and_ttl_are_configurable_without_serializing_secret(
+    monkeypatch,
+):
+    secret = "test-shared-state-handle-secret-value"
+    monkeypatch.setenv("MCP_STATE_HANDLE_SECRET", secret)
+    monkeypatch.setenv("MCP_STATE_HANDLE_TTL_SECONDS", "45")
+
+    configured = DorisConfig.from_env()
+    assert configured.mcp_state_handle_secret == secret
+    assert configured.mcp_state_handle_ttl_seconds == 45
+    assert "mcp_state_handle_secret" not in configured.to_dict()
+    assert configured.to_dict()["mcp_state_handle_ttl_seconds"] == 45
+    assert configured.validate() == []
+
+    configured.mcp_state_handle_secret = "short"
+    assert (
+        "MCP state handle secret must contain at least 32 bytes"
+        in configured.validate()
+    )
+    configured.mcp_state_handle_secret = secret
+    configured.mcp_state_handle_ttl_seconds = 3601
+    assert (
+        "MCP state handle TTL must be in the range 1-3600 seconds"
+        in configured.validate()
+    )
+
+
 def test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
     config = DorisConfig()
     config.database.host = "127.0.0.1"
@@ -55,6 +82,8 @@ def 
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
     config.mcp_allowed_origins = ["https://client.example.test";]
     config.enable_legacy_http_adapter = True
     config.mcp_list_page_size = 17
+    config.mcp_state_handle_secret = "parent-shared-state-handle-secret-value"
+    config.mcp_state_handle_ttl_seconds = 45
 
     worker_env = _multiworker_environment(
         config,
@@ -82,6 +111,11 @@ 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.mcp_state_handle_secret
+        == "parent-shared-state-handle-secret-value"
+    )
+    assert child_config.mcp_state_handle_ttl_seconds == 45
     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
index f0ef5d2..d7e37aa 100644
--- a/test/protocol/test_protocol_pagination.py
+++ b/test/protocol/test_protocol_pagination.py
@@ -14,7 +14,7 @@
 # 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."""
+"""Explicit, permission-bound pagination handles for MCP list operations."""
 
 from __future__ import annotations
 
@@ -214,6 +214,18 @@ async def 
test_cursor_rejects_wrong_collection_malformed_and_stale_snapshots():
         assert malformed.value.code == INVALID_PARAMS
         assert malformed.value.data == {"cursorError": "invalid"}
 
+        tampered_parts = first_tools.next_cursor.split(".")
+        tampered_parts[1] = (
+            "A" if tampered_parts[1][0] != "A" else "B"
+        ) + tampered_parts[1][1:]
+        with pytest.raises(MCPError) as tampered:
+            await client.list_tools(
+                cursor=".".join(tampered_parts),
+                cache_mode="bypass",
+            )
+        assert tampered.value.code == INVALID_PARAMS
+        assert tampered.value.data == {"cursorError": "invalid"}
+
         managers.tools.tools.append(
             Tool(
                 name="foxtrot",
@@ -272,3 +284,56 @@ async def 
test_cursor_is_bound_to_the_authorization_context():
     assert changed_context.value.data == {
         "cursorError": "authorization_context_changed"
     }
+
+
[email protected]
+async def 
test_cursor_crosses_server_instances_with_shared_secret_not_session():
+    secret = "pagination-shared-state-handle-secret-value"
+    first_server, _ = create_pagination_server(
+        page_size=1,
+        state_handle_secret=secret,
+    )
+    second_server, _ = create_pagination_server(
+        page_size=1,
+        state_handle_secret=secret,
+    )
+    first_entry = first_server.get_request_handler("tools/list")
+    second_entry = second_server.get_request_handler("tools/list")
+    assert first_entry is not None
+    assert second_entry is not None
+    context = SimpleNamespace(protocol_version=LATEST_PROTOCOL_VERSION)
+
+    first_request = AuthContext(
+        token_id="token-a",
+        user_id="user-a",
+        permissions=["tool:list"],
+        roles=["analyst"],
+        auth_method="token",
+        session_id="transport-session-one",
+    )
+    token = set_current_auth_context(first_request)
+    try:
+        first_page = await first_entry.handler(context, 
PaginatedRequestParams())
+    finally:
+        reset_auth_context(token)
+    assert first_page.next_cursor is not None
+
+    second_request = AuthContext(
+        token_id="token-a",
+        user_id="user-a",
+        permissions=["tool:list"],
+        roles=["analyst"],
+        auth_method="token",
+        session_id="transport-session-two",
+    )
+    token = set_current_auth_context(second_request)
+    try:
+        second_page = await second_entry.handler(
+            context,
+            PaginatedRequestParams(cursor=first_page.next_cursor),
+        )
+    finally:
+        reset_auth_context(token)
+
+    assert [tool.name for tool in first_page.tools] == ["alpha"]
+    assert [tool.name for tool in second_page.tools] == ["bravo"]
diff --git a/test/protocol/test_state_handles.py 
b/test/protocol/test_state_handles.py
new file mode 100644
index 0000000..f9c1196
--- /dev/null
+++ b/test/protocol/test_state_handles.py
@@ -0,0 +1,246 @@
+# 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.
+"""Contract tests for explicit state handles across stateless MCP calls."""
+
+from __future__ import annotations
+
+import pytest
+
+from doris_mcp_server.state_handles import (
+    StateHandleCodec,
+    StateHandleError,
+    authorization_fingerprint,
+)
+from doris_mcp_server.utils.security import AuthContext
+
+_SHARED_SECRET = "shared-state-handle-secret-value-32-bytes"
+
+
+def _auth_context(
+    *,
+    user_id: str = "user-a",
+    session_id: str = "request-session-a",
+    scopes: list[str] | None = None,
+) -> AuthContext:
+    return AuthContext(
+        token_id="token-a",
+        user_id=user_id,
+        roles=["analyst"],
+        permissions=["tool:list"],
+        session_id=session_id,
+        token="must-not-enter-handle",
+        auth_method="doris_oauth",
+        oauth_client_id="client-a",
+        oauth_scopes=scopes or ["mcp:tools"],
+        oauth_token_id="oauth-token-a",
+        oauth_issuer="https://auth.example.test";,
+        oauth_resource="https://mcp.example.test";,
+        oauth_audiences=["https://mcp.example.test";],
+    )
+
+
+def _codec(now: list[float]) -> StateHandleCodec:
+    return StateHandleCodec(
+        _SHARED_SECRET,
+        default_ttl_seconds=60,
+        clock=lambda: now[0],
+        nonce_factory=lambda: "fixed-handle-id",
+    )
+
+
+def test_handle_round_trip_is_explicit_bounded_and_session_independent():
+    now = [1_000.0]
+    codec = _codec(now)
+    first_context = _auth_context(session_id="protocol-session-a")
+    handle = codec.issue(
+        kind="list-page",
+        scope="tools:list",
+        resource="mcp://tools",
+        state={"after": "bravo", "snapshot": "digest"},
+        auth_context=first_context,
+    )
+
+    assert handle.startswith("mcp-h1.")
+    assert first_context.user_id not in handle
+    assert first_context.token not in handle
+    assert authorization_fingerprint(first_context) == 
authorization_fingerprint(
+        _auth_context(session_id="different-protocol-session")
+    )
+
+    claims = codec.resolve(
+        handle,
+        expected_kind="list-page",
+        expected_scope="tools:list",
+        expected_resource="mcp://tools",
+        auth_context=_auth_context(session_id="different-protocol-session"),
+    )
+    assert claims.handle_id == "fixed-handle-id"
+    assert claims.expires_at == 1_060
+    assert claims.state == {"after": "bravo", "snapshot": "digest"}
+
+
+def test_handle_is_portable_across_instances_only_with_the_shared_secret():
+    now = [1_000.0]
+    issued = _codec(now).issue(
+        kind="list-page",
+        scope="tools:list",
+        resource="mcp://tools",
+        state={"after": "bravo"},
+        auth_context=_auth_context(),
+    )
+
+    claims = StateHandleCodec(
+        _SHARED_SECRET,
+        clock=lambda: now[0],
+    ).resolve(
+        issued,
+        expected_kind="list-page",
+        expected_scope="tools:list",
+        expected_resource="mcp://tools",
+        auth_context=_auth_context(),
+    )
+    assert claims.state == {"after": "bravo"}
+
+    with pytest.raises(StateHandleError, match="invalid"):
+        StateHandleCodec(
+            "another-shared-state-handle-secret-value",
+            clock=lambda: now[0],
+        ).resolve(
+            issued,
+            expected_kind="list-page",
+            expected_scope="tools:list",
+            expected_resource="mcp://tools",
+            auth_context=_auth_context(),
+        )
+
+
[email protected](
+    ("context", "kind", "scope", "resource", "reason"),
+    [
+        (
+            _auth_context(user_id="user-b"),
+            "list-page",
+            "tools:list",
+            "mcp://tools",
+            "authorization_context_changed",
+        ),
+        (_auth_context(), "other-kind", "tools:list", "mcp://tools", 
"wrong_kind"),
+        (_auth_context(), "list-page", "resources:list", "mcp://tools", 
"wrong_scope"),
+        (
+            _auth_context(),
+            "list-page",
+            "tools:list",
+            "mcp://resources",
+            "wrong_resource",
+        ),
+    ],
+)
+def test_handle_rejects_cross_principal_scope_resource_and_kind(
+    context: AuthContext,
+    kind: str,
+    scope: str,
+    resource: str,
+    reason: str,
+):
+    now = [1_000.0]
+    codec = _codec(now)
+    handle = codec.issue(
+        kind="list-page",
+        scope="tools:list",
+        resource="mcp://tools",
+        state={"after": "bravo"},
+        auth_context=_auth_context(),
+    )
+
+    with pytest.raises(StateHandleError, match=reason):
+        codec.resolve(
+            handle,
+            expected_kind=kind,
+            expected_scope=scope,
+            expected_resource=resource,
+            auth_context=context,
+        )
+
+
+def test_handle_rejects_expiry_and_tampering_before_exposing_state():
+    now = [1_000.0]
+    codec = _codec(now)
+    handle = codec.issue(
+        kind="list-page",
+        scope="tools:list",
+        resource="mcp://tools",
+        state={"after": "bravo"},
+        auth_context=_auth_context(),
+        ttl_seconds=10,
+    )
+
+    now[0] = 1_010.0
+    with pytest.raises(StateHandleError, match="expired"):
+        codec.resolve(
+            handle,
+            expected_kind="list-page",
+            expected_scope="tools:list",
+            expected_resource="mcp://tools",
+            auth_context=_auth_context(),
+        )
+
+    parts = handle.split(".")
+    parts[1] = ("A" if parts[1][0] != "A" else "B") + parts[1][1:]
+    with pytest.raises(StateHandleError, match="invalid"):
+        codec.resolve(
+            ".".join(parts),
+            expected_kind="list-page",
+            expected_scope="tools:list",
+            expected_resource="mcp://tools",
+            auth_context=_auth_context(),
+        )
+
+
+def test_handle_enforces_secret_ttl_claim_and_payload_limits():
+    with pytest.raises(ValueError, match="at least 32 bytes"):
+        StateHandleCodec("too-short")
+    with pytest.raises(ValueError, match="TTL"):
+        StateHandleCodec(_SHARED_SECRET, default_ttl_seconds=0)
+
+    codec = StateHandleCodec(_SHARED_SECRET)
+    with pytest.raises(ValueError, match="resource"):
+        codec.issue(
+            kind="list-page",
+            scope="tools:list",
+            resource="",
+            state={},
+            auth_context=None,
+        )
+    with pytest.raises(ValueError, match="payload exceeds"):
+        codec.issue(
+            kind="list-page",
+            scope="tools:list",
+            resource="mcp://tools",
+            state={"oversized": "x" * 3_000},
+            auth_context=None,
+        )
+    with pytest.raises(ValueError, match="handle_id"):
+        StateHandleCodec(
+            _SHARED_SECRET,
+            nonce_factory=lambda: "",
+        ).issue(
+            kind="list-page",
+            scope="tools:list",
+            resource="mcp://tools",
+            state={},
+            auth_context=None,
+        )


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

Reply via email to