refactor: split RequestContext between server and client (#1987)

This commit is contained in:
Marcelo Trylesinski 2026-02-03 14:35:07 +01:00 • committed by GitHub
commit b1f7eec3cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 210 additions and 212 deletions

View file

@ -11,8 +11,10 @@ Use cases:
- Server polls client's task status via tasks/get, tasks/result, etc.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Protocol
from pydantic import TypeAdapter
@ -32,7 +34,7 @@ class GetTaskHandlerFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.GetTaskRequestParams,
) -> types.GetTaskResult | types.ErrorData: ... # pragma: no branch
@ -45,7 +47,7 @@ class GetTaskResultHandlerFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.GetTaskPayloadRequestParams,
) -> types.GetTaskPayloadResult | types.ErrorData: ... # pragma: no branch
@ -58,7 +60,7 @@ class ListTasksHandlerFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.PaginatedRequestParams | None,
) -> types.ListTasksResult | types.ErrorData: ... # pragma: no branch
@ -71,7 +73,7 @@ class CancelTaskHandlerFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.CancelTaskRequestParams,
) -> types.CancelTaskResult | types.ErrorData: ... # pragma: no branch
@ -88,7 +90,7 @@ class TaskAugmentedSamplingFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.CreateMessageRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch
@ -106,14 +108,14 @@ class TaskAugmentedElicitationFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.ElicitRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch
async def default_get_task_handler(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.GetTaskRequestParams,
) -> types.GetTaskResult | types.ErrorData:
return types.ErrorData(
@ -123,7 +125,7 @@ async def default_get_task_handler(
async def default_get_task_result_handler(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.GetTaskPayloadRequestParams,
) -> types.GetTaskPayloadResult | types.ErrorData:
return types.ErrorData(
@ -133,7 +135,7 @@ async def default_get_task_result_handler(
async def default_list_tasks_handler(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.PaginatedRequestParams | None,
) -> types.ListTasksResult | types.ErrorData:
return types.ErrorData(
@ -143,7 +145,7 @@ async def default_list_tasks_handler(
async def default_cancel_task_handler(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.CancelTaskRequestParams,
) -> types.CancelTaskResult | types.ErrorData:
return types.ErrorData(
@ -153,7 +155,7 @@ async def default_cancel_task_handler(
async def default_task_augmented_sampling(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.CreateMessageRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData:
@ -164,7 +166,7 @@ async def default_task_augmented_sampling(
async def default_task_augmented_elicitation(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.ElicitRequestParams,
task_metadata: types.TaskMetadata,
) -> types.CreateTaskResult | types.ErrorData:
@ -248,7 +250,7 @@ class ExperimentalTaskHandlers:
async def handle_request(
self,
ctx: RequestContext["ClientSession", Any],
ctx: RequestContext[ClientSession],
responder: RequestResponder[types.ServerRequest, types.ClientResult],
) -> None:
"""Handle a task-related request from the server.

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import logging
from typing import Any, Protocol
@ -22,7 +24,7 @@ logger = logging.getLogger("client")
class SamplingFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch
@ -30,22 +32,19 @@ class SamplingFnT(Protocol):
class ElicitationFnT(Protocol):
async def __call__(
self,
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch
class ListRootsFnT(Protocol):
async def __call__(
self, context: RequestContext["ClientSession", Any]
self, context: RequestContext[ClientSession]
) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch
class LoggingFnT(Protocol):
async def __call__(
self,
params: types.LoggingMessageNotificationParams,
) -> None: ... # pragma: no branch
async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch
class MessageHandlerFnT(Protocol):
@ -62,7 +61,7 @@ async def _default_message_handler(
async def _default_sampling_callback(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData:
return types.ErrorData(
@ -72,7 +71,7 @@ async def _default_sampling_callback(
async def _default_elicitation_callback(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
params: types.ElicitRequestParams,
) -> types.ElicitResult | types.ErrorData:
return types.ErrorData( # pragma: no cover
@ -82,7 +81,7 @@ async def _default_elicitation_callback(
async def _default_list_roots_callback(
context: RequestContext["ClientSession", Any],
context: RequestContext[ClientSession],
) -> types.ListRootsResult | types.ErrorData:
return types.ErrorData(
code=types.INVALID_REQUEST,
@ -153,10 +152,7 @@ class ClientSession(
else None
)
elicitation = (
types.ElicitationCapability(
form=types.FormElicitationCapability(),
url=types.UrlElicitationCapability(),
)
types.ElicitationCapability(form=types.FormElicitationCapability(), url=types.UrlElicitationCapability())
if self._elicitation_callback is not _default_elicitation_callback
else None
)
@ -414,12 +410,7 @@ class ClientSession(
await self.send_notification(types.RootsListChangedNotification())
async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None:
ctx = RequestContext[ClientSession, Any](
request_id=responder.request_id,
meta=responder.request_meta,
session=self,
lifespan_context=None,
)
ctx = RequestContext[ClientSession](request_id=responder.request_id, meta=responder.request_meta, session=self)
# Delegate to experimental task handler if applicable
if self._task_handlers.handles_request(responder.request):

23
src/mcp/server/context.py Normal file
View file

@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Generic
from typing_extensions import TypeVar
from mcp.server.experimental.request_context import Experimental
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext
from mcp.shared.message import CloseSSEStreamCallback
LifespanContextT = TypeVar("LifespanContextT")
RequestT = TypeVar("RequestT", default=Any)
@dataclass(kw_only=True)
class ServerRequestContext(RequestContext[ServerSession], Generic[LifespanContextT, RequestT]):
lifespan_context: LifespanContextT
experimental: Experimental
request: RequestT | None = None
close_sse_stream: CloseSSEStreamCallback | None = None
close_standalone_sse_stream: CloseSSEStreamCallback | None = None

View file

@ -57,10 +57,7 @@ class Experimental:
return self._client_capabilities.tasks is not None
def validate_task_mode(
self,
tool_task_mode: TaskExecutionMode | None,
*,
raise_error: bool = True,
self, tool_task_mode: TaskExecutionMode | None, *, raise_error: bool = True
) -> ErrorData | None:
"""Validate that the request is compatible with the tool's task execution mode.
@ -95,12 +92,7 @@ class Experimental:
return error
def validate_for_tool(
self,
tool: Tool,
*,
raise_error: bool = True,
) -> ErrorData | None:
def validate_for_tool(self, tool: Tool, *, raise_error: bool = True) -> ErrorData | None:
"""Validate that the request is compatible with the given tool.
Convenience wrapper around validate_task_mode that extracts the mode from a Tool.

View file

@ -91,6 +91,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAut
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerRequestContext
from mcp.server.experimental.request_context import Experimental
from mcp.server.lowlevel.experimental import ExperimentalHandlers
from mcp.server.lowlevel.func_inspection import create_call_wrapper
@ -100,7 +101,6 @@ from mcp.server.session import ServerSession
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import RequestContext
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
@ -117,16 +117,11 @@ UnstructuredContent: TypeAlias = Iterable[types.ContentBlock]
CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent]
# This will be properly typed in each Server instance's context
request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx")
request_ctx: contextvars.ContextVar[ServerRequestContext[Any, Any]] = contextvars.ContextVar("request_ctx")
class NotificationOptions:
def __init__(
self,
prompts_changed: bool = False,
resources_changed: bool = False,
tools_changed: bool = False,
):
def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False):
self.prompts_changed = prompts_changed
self.resources_changed = resources_changed
self.tools_changed = tools_changed
@ -253,9 +248,7 @@ class Server(Generic[LifespanResultT, RequestT]):
return capabilities
@property
def request_context(
self,
) -> RequestContext[ServerSession, LifespanResultT, RequestT]:
def request_context(self) -> ServerRequestContext[LifespanResultT, RequestT]:
"""If called outside of a request context, this will raise a LookupError."""
return request_ctx.get()
@ -762,12 +755,12 @@ class Server(Generic[LifespanResultT, RequestT]):
if hasattr(req, "params") and req.params is not None:
task_metadata = getattr(req.params, "task", None)
token = request_ctx.set(
RequestContext(
message.request_id,
message.request_meta,
session,
lifespan_context,
Experimental(
ServerRequestContext(
request_id=message.request_id,
meta=message.request_meta,
session=session,
lifespan_context=lifespan_context,
experimental=Experimental(
task_metadata=task_metadata,
_client_capabilities=client_capabilities,
_session=session,

View file

@ -14,9 +14,8 @@ from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.types import ContentBlock, Icon, TextContent
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT
from mcp.shared.context import LifespanContextT, RequestT
class Message(BaseModel):
@ -137,7 +136,7 @@ class Prompt(BaseModel):
async def render(
self,
arguments: dict[str, Any] | None = None,
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
context: Context[LifespanContextT, RequestT] | None = None,
) -> list[Message]:
"""Render the prompt with arguments."""
# Validate required arguments

View file

@ -8,9 +8,8 @@ from mcp.server.mcpserver.prompts.base import Message, Prompt
from mcp.server.mcpserver.utilities.logging import get_logger
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT
from mcp.shared.context import LifespanContextT, RequestT
logger = get_logger(__name__)
@ -50,7 +49,7 @@ class PromptManager:
self,
name: str,
arguments: dict[str, Any] | None = None,
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
context: Context[LifespanContextT, RequestT] | None = None,
) -> list[Message]:
"""Render a prompt by name with arguments."""
prompt = self.get_prompt(name)

View file

@ -13,9 +13,8 @@ from mcp.server.mcpserver.utilities.logging import get_logger
from mcp.types import Annotations, Icon
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT
from mcp.shared.context import LifespanContextT, RequestT
logger = get_logger(__name__)
@ -82,7 +81,7 @@ class ResourceManager:
return template
async def get_resource(
self, uri: AnyUrl | str, context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None
self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] | None = None
) -> Resource:
"""Get resource by URI, checking concrete resources first, then templates."""
uri_str = str(uri)

View file

@ -16,9 +16,8 @@ from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.types import Annotations, Icon
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT
from mcp.shared.context import LifespanContextT, RequestT
class ResourceTemplate(BaseModel):
@ -100,7 +99,7 @@ class ResourceTemplate(BaseModel):
self,
uri: str,
params: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
context: Context[LifespanContextT, RequestT] | None = None,
) -> Resource:
"""Create a resource from the template with the given parameters."""
try:

View file

@ -25,6 +25,7 @@ from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, UrlElicitationResult, elicit_with_validation
from mcp.server.elicitation import elicit_url as _elicit_url
from mcp.server.lowlevel.helper_types import ReadResourceContents
@ -36,13 +37,11 @@ from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceM
from mcp.server.mcpserver.tools import Tool, ToolManager
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
from mcp.server.session import ServerSession, ServerSessionT
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
from mcp.types import Annotations, ContentBlock, GetPromptResult, Icon, ToolAnnotations
from mcp.types import Prompt as MCPPrompt
from mcp.types import PromptArgument as MCPPromptArgument
@ -294,7 +293,7 @@ class MCPServer(Generic[LifespanResultT]):
for info in tools
]
def get_context(self) -> Context[ServerSession, LifespanResultT, Request]:
def get_context(self) -> Context[LifespanResultT, Request]:
"""Returns a Context object. Note that the context will only be valid
during a request; outside a request, most methods will error.
"""
@ -972,7 +971,7 @@ class MCPServer(Generic[LifespanResultT]):
raise ValueError(str(e))
class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
class Context(BaseModel, Generic[LifespanContextT, RequestT]):
"""Context object providing access to MCP capabilities.
This provides a cleaner interface to MCP's RequestContext functionality.
@ -1006,14 +1005,15 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
The context is optional - tools that don't need it can omit the parameter.
"""
_request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
_mcp_server: MCPServer | None
def __init__(
self,
*,
request_context: (RequestContext[ServerSessionT, LifespanContextT, RequestT] | None) = None,
request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None,
mcp_server: MCPServer | None = None,
# TODO(Marcelo): We should drop this kwargs parameter.
**kwargs: Any,
):
super().__init__(**kwargs)
@ -1028,9 +1028,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
return self._mcp_server # pragma: no cover
@property
def request_context(
self,
) -> RequestContext[ServerSessionT, LifespanContextT, RequestT]:
def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
"""Access to the underlying request context."""
if self._request_context is None: # pragma: no cover
raise ValueError("Context is not available outside of a request")

View file

@ -16,9 +16,8 @@ from mcp.shared.tool_name_validation import validate_and_warn_tool_name
from mcp.types import Icon, ToolAnnotations
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT
from mcp.shared.context import LifespanContextT, RequestT
class Tool(BaseModel):
@ -93,7 +92,7 @@ class Tool(BaseModel):
async def run(
self,
arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
context: Context[LifespanContextT, RequestT] | None = None,
convert_result: bool = False,
) -> Any:
"""Run the tool with arguments."""

View file

@ -6,12 +6,11 @@ from typing import TYPE_CHECKING, Any
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.tools.base import Tool
from mcp.server.mcpserver.utilities.logging import get_logger
from mcp.shared.context import LifespanContextT, RequestT
from mcp.types import Icon, ToolAnnotations
if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.server import Context
from mcp.server.session import ServerSessionT
logger = get_logger(__name__)
@ -82,7 +81,7 @@ class ToolManager:
self,
name: str,
arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
context: Context[LifespanContextT, RequestT] | None = None,
convert_result: bool = False,
) -> Any:
"""Call a tool by name with arguments."""

View file

@ -1,30 +1,20 @@
"""Request context for MCP handlers."""
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any, Generic
from typing_extensions import TypeVar
from mcp.shared.message import CloseSSEStreamCallback
from mcp.shared.session import BaseSession
from mcp.types import RequestId, RequestParamsMeta
SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any])
LifespanContextT = TypeVar("LifespanContextT")
RequestT = TypeVar("RequestT", default=Any)
@dataclass
class RequestContext(Generic[SessionT, LifespanContextT, RequestT]):
@dataclass(kw_only=True)
class RequestContext(Generic[SessionT]):
"""Common context for handling incoming requests."""
request_id: RequestId
meta: RequestParamsMeta | None
session: SessionT
lifespan_context: LifespanContextT
# NOTE: This is typed as Any to avoid circular imports. The actual type is
# mcp.server.experimental.request_context.Experimental, but importing it here
# triggers mcp.server.__init__ -> mcpserver -> tools -> back to this module.
# The Server sets this to an Experimental instance at runtime.
experimental: Any = field(default=None)
request: RequestT | None = None
close_sse_stream: CloseSSEStreamCallback | None = None
close_standalone_sse_stream: CloseSSEStreamCallback | None = None

View file

@ -5,15 +5,7 @@ from typing import Generic
from pydantic import BaseModel
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.shared.session import (
BaseSession,
ReceiveNotificationT,
ReceiveRequestT,
SendNotificationT,
SendRequestT,
SendResultT,
)
from mcp.shared.context import RequestContext, SessionT
from mcp.types import ProgressToken
@ -23,8 +15,8 @@ class Progress(BaseModel):
@dataclass
class ProgressContext(Generic[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT]):
session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT]
class ProgressContext(Generic[SessionT]):
session: SessionT
progress_token: ProgressToken
total: float | None
current: float = field(default=0.0, init=False)
@ -39,15 +31,9 @@ class ProgressContext(Generic[SendRequestT, SendNotificationT, SendResultT, Rece
@contextmanager
def progress(
ctx: RequestContext[
BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT],
LifespanContextT,
],
ctx: RequestContext[SessionT],
total: float | None = None,
) -> Generator[
ProgressContext[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT],
None,
]:
) -> Generator[ProgressContext[SessionT], None]:
progress_token = ctx.meta.get("progress_token") if ctx.meta else None
if progress_token is None: # pragma: no cover
raise ValueError("No progress token provided")