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]