490 lines
20 KiB
Python
490 lines
20 KiB
Python
"""`ServerSession`: server-to-client requests and notifications.
|
|
|
|
A per-request proxy built by the kernel for each inbound request. Exposes the
|
|
request-scoped outbound channel and the connection's standalone channel.
|
|
Handlers reach it as `ctx.session` and use the typed helpers (`elicit_form`,
|
|
`send_log_message`, ...) to call back to the client.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, TypeVar, overload
|
|
|
|
import mcp_types as types
|
|
from mcp_types import methods as _methods
|
|
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
|
from pydantic import AnyUrl, BaseModel
|
|
from typing_extensions import deprecated
|
|
|
|
from mcp.server.connection import Connection, allowed_log_levels
|
|
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools
|
|
from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
|
|
from mcp.shared.exceptions import MCPDeprecationWarning
|
|
from mcp.shared.message import ServerMessageMetadata
|
|
|
|
__all__ = ["ServerSession"]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# `send_log_message`'s `logger` parameter (public API, the spec's logger-name
|
|
# field) shadows the module logger inside that method; this alias keeps it
|
|
# reachable there.
|
|
_logger = logger
|
|
|
|
ResultT = TypeVar("ResultT", bound=BaseModel)
|
|
|
|
|
|
class ServerSession:
|
|
"""Per-request proxy for server-to-client requests and notifications.
|
|
|
|
Built once per inbound request by the kernel's `_make_context`. Holds two
|
|
`Outbound` channels: the request-scoped one (the per-request
|
|
`DispatchContext`, which on streamable HTTP routes onto the originating
|
|
POST's response stream) and the connection's standalone channel
|
|
(`connection.outbound`). `related_request_id` on the public methods is the
|
|
selector — present means request-scoped, absent means standalone — and
|
|
never crosses the `Outbound` Protocol.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
request_outbound: DispatchContext[Any],
|
|
connection: Connection,
|
|
*,
|
|
request_meta: types.RequestParamsMeta | None = None,
|
|
) -> None:
|
|
self._request_outbound = request_outbound
|
|
self._connection = connection
|
|
# The per-request log-delivery contract, fixed at construction: on
|
|
# 2026-07-28+ the inbound request's `_meta` log-level opt-in decides
|
|
# which `notifications/message` levels may be sent for this request
|
|
# (and they ride this request's stream only); on handshake versions
|
|
# every level may be sent (`logging/setLevel`-era semantics).
|
|
self._log_is_request_scoped = connection.protocol_version in MODERN_PROTOCOL_VERSIONS
|
|
self._allowed_log_levels = allowed_log_levels(connection.protocol_version, request_meta)
|
|
|
|
@property
|
|
def client_params(self) -> types.InitializeRequestParams | None:
|
|
"""The client's `initialize` request params; `None` when no client info was supplied."""
|
|
return self._connection.client_params
|
|
|
|
@property
|
|
def client_capabilities(self) -> types.ClientCapabilities | None:
|
|
"""The capabilities the client declared; `None` when none were declared.
|
|
|
|
Prefer this over `client_params.capabilities`: on 2026-07-28+ the
|
|
request envelope declares capabilities while client info stays
|
|
optional, so capabilities can be present without `client_params`.
|
|
"""
|
|
return self._connection.client_capabilities
|
|
|
|
@property
|
|
def can_send_request(self) -> bool:
|
|
"""Whether this request's channel can currently deliver a server-initiated request."""
|
|
return self._request_outbound.can_send_request
|
|
|
|
@property
|
|
def protocol_version(self) -> str:
|
|
"""The protocol version this connection speaks.
|
|
|
|
Populated at `Connection` construction and overwritten once the
|
|
handshake commits on the loop path; never `None`.
|
|
"""
|
|
return self._connection.protocol_version
|
|
|
|
async def send_request(
|
|
self,
|
|
request: types.ServerRequest,
|
|
result_type: type[ResultT],
|
|
request_read_timeout_seconds: float | None = None,
|
|
metadata: ServerMessageMetadata | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
) -> ResultT:
|
|
"""Send a typed server-to-client request and validate the result.
|
|
|
|
Raises:
|
|
MCPError: The peer responded with an error.
|
|
NoBackChannelError: The connection has no back-channel for
|
|
server-initiated requests (raised by the held `Outbound`).
|
|
pydantic.ValidationError: The peer's result does not match `result_type`.
|
|
"""
|
|
related = metadata.related_request_id if metadata is not None else None
|
|
channel = self._request_outbound if related is not None else self._connection.outbound
|
|
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
opts: CallOptions = {}
|
|
if request_read_timeout_seconds is not None:
|
|
opts["timeout"] = request_read_timeout_seconds
|
|
if progress_callback is not None:
|
|
opts["on_progress"] = progress_callback
|
|
result = await channel.send_raw_request(data["method"], data.get("params"), opts or None)
|
|
try:
|
|
_methods.validate_client_result(request.method, self.protocol_version, result)
|
|
except KeyError:
|
|
pass
|
|
return result_type.model_validate(result, by_name=False)
|
|
|
|
async def send_notification(
|
|
self,
|
|
notification: types.ServerNotification,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> None:
|
|
"""Send a typed server-to-client notification."""
|
|
await self._notify(notification, request_scoped=related_request_id is not None)
|
|
|
|
async def _notify(self, notification: types.ServerNotification, *, request_scoped: bool) -> None:
|
|
channel = self._request_outbound if request_scoped else self._connection.outbound
|
|
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
await channel.notify(data["method"], data.get("params"))
|
|
|
|
def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
|
|
"""Check if the client supports a specific capability."""
|
|
return self._connection.check_capability(capability)
|
|
|
|
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def send_log_message(
|
|
self,
|
|
level: types.LoggingLevel,
|
|
data: Any,
|
|
logger: str | None = None,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> None:
|
|
"""Send a log message notification.
|
|
|
|
On 2026-07-28+ delivery is a per-request opt-in: nothing is sent
|
|
unless this request's `_meta` carried the reserved log-level key, and
|
|
entries below the requested level are dropped (debug-logged). What is
|
|
sent rides this request's stream regardless of `related_request_id` -
|
|
the spec forbids `notifications/message` on any stream but the one
|
|
carrying the response. Handshake versions send unconditionally on the
|
|
channel `related_request_id` selects, as before.
|
|
"""
|
|
if level not in self._allowed_log_levels:
|
|
_logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level)
|
|
return
|
|
await self._notify(
|
|
types.LoggingMessageNotification(
|
|
params=types.LoggingMessageNotificationParams(
|
|
level=level,
|
|
data=data,
|
|
logger=logger,
|
|
),
|
|
),
|
|
request_scoped=self._log_is_request_scoped or related_request_id is not None,
|
|
)
|
|
|
|
async def send_resource_updated(self, uri: str | AnyUrl) -> None:
|
|
"""Send a resource updated notification."""
|
|
await self.send_notification(
|
|
types.ResourceUpdatedNotification(
|
|
params=types.ResourceUpdatedNotificationParams(uri=str(uri)),
|
|
)
|
|
)
|
|
|
|
@overload
|
|
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def create_message(
|
|
self,
|
|
messages: list[types.SamplingMessage],
|
|
*,
|
|
max_tokens: int,
|
|
system_prompt: str | None = None,
|
|
include_context: types.IncludeContext | None = None,
|
|
temperature: float | None = None,
|
|
stop_sequences: list[str] | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
model_preferences: types.ModelPreferences | None = None,
|
|
tools: None = None,
|
|
tool_choice: None = None,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.CreateMessageResult:
|
|
"""Overload: Without tools or tool_choice, returns single content."""
|
|
...
|
|
|
|
@overload
|
|
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def create_message(
|
|
self,
|
|
messages: list[types.SamplingMessage],
|
|
*,
|
|
max_tokens: int,
|
|
system_prompt: str | None = None,
|
|
include_context: types.IncludeContext | None = None,
|
|
temperature: float | None = None,
|
|
stop_sequences: list[str] | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
model_preferences: types.ModelPreferences | None = None,
|
|
tools: list[types.Tool],
|
|
tool_choice: types.ToolChoice | None = None,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.CreateMessageResultWithTools:
|
|
"""Overload: With tools, returns array-capable content."""
|
|
...
|
|
|
|
@overload
|
|
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def create_message(
|
|
self,
|
|
messages: list[types.SamplingMessage],
|
|
*,
|
|
max_tokens: int,
|
|
system_prompt: str | None = None,
|
|
include_context: types.IncludeContext | None = None,
|
|
temperature: float | None = None,
|
|
stop_sequences: list[str] | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
model_preferences: types.ModelPreferences | None = None,
|
|
tools: list[types.Tool] | None = None,
|
|
tool_choice: types.ToolChoice,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.CreateMessageResultWithTools:
|
|
"""Overload: With tool_choice, returns array-capable content."""
|
|
...
|
|
|
|
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def create_message(
|
|
self,
|
|
messages: list[types.SamplingMessage],
|
|
*,
|
|
max_tokens: int,
|
|
system_prompt: str | None = None,
|
|
include_context: types.IncludeContext | None = None,
|
|
temperature: float | None = None,
|
|
stop_sequences: list[str] | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
model_preferences: types.ModelPreferences | None = None,
|
|
tools: list[types.Tool] | None = None,
|
|
tool_choice: types.ToolChoice | None = None,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.CreateMessageResult | types.CreateMessageResultWithTools:
|
|
"""Send a sampling/create_message request.
|
|
|
|
Args:
|
|
messages: The conversation messages to send.
|
|
max_tokens: Maximum number of tokens to generate.
|
|
system_prompt: Optional system prompt.
|
|
include_context: Optional context inclusion setting.
|
|
Should only be set to "thisServer" or "allServers"
|
|
if the client has sampling.context capability.
|
|
temperature: Optional sampling temperature.
|
|
stop_sequences: Optional stop sequences.
|
|
metadata: Optional metadata to pass through to the LLM provider.
|
|
model_preferences: Optional model selection preferences.
|
|
tools: Optional list of tools the LLM can use during sampling.
|
|
Requires client to have sampling.tools capability.
|
|
tool_choice: Optional control over tool usage behavior.
|
|
Requires client to have sampling.tools capability.
|
|
related_request_id: Optional ID of a related request.
|
|
|
|
Returns:
|
|
The sampling result from the client.
|
|
|
|
Raises:
|
|
MCPError: If tools are provided but client doesn't support them.
|
|
ValueError: If tool_use or tool_result message structure is invalid.
|
|
NoBackChannelError: The connection has no back-channel for
|
|
server-initiated requests.
|
|
"""
|
|
validate_sampling_tools(self.client_capabilities, tools, tool_choice)
|
|
validate_tool_use_result_messages(messages)
|
|
|
|
request = types.CreateMessageRequest(
|
|
params=types.CreateMessageRequestParams(
|
|
messages=messages,
|
|
system_prompt=system_prompt,
|
|
include_context=include_context,
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
stop_sequences=stop_sequences,
|
|
metadata=metadata,
|
|
model_preferences=model_preferences,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
),
|
|
)
|
|
metadata_obj = ServerMessageMetadata(related_request_id=related_request_id)
|
|
|
|
if wants_sampling_tools(tools, tool_choice):
|
|
return await self.send_request(
|
|
request=request,
|
|
result_type=types.CreateMessageResultWithTools,
|
|
metadata=metadata_obj,
|
|
)
|
|
return await self.send_request(
|
|
request=request,
|
|
result_type=types.CreateMessageResult,
|
|
metadata=metadata_obj,
|
|
)
|
|
|
|
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def list_roots(self) -> types.ListRootsResult:
|
|
"""Send a roots/list request.
|
|
|
|
Raises:
|
|
NoBackChannelError: The connection has no back-channel for
|
|
server-initiated requests.
|
|
"""
|
|
return await self.send_request(
|
|
types.ListRootsRequest(),
|
|
types.ListRootsResult,
|
|
)
|
|
|
|
async def elicit(
|
|
self,
|
|
message: str,
|
|
requested_schema: types.ElicitRequestedSchema,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.ElicitResult:
|
|
"""Send a form mode elicitation/create request.
|
|
|
|
Args:
|
|
message: The message to present to the user.
|
|
requested_schema: Schema defining the expected response structure.
|
|
related_request_id: Optional ID of the request that triggered this elicitation.
|
|
|
|
Returns:
|
|
The client's response.
|
|
|
|
Note:
|
|
This method is deprecated in favor of elicit_form(). It remains for
|
|
backward compatibility but new code should use elicit_form().
|
|
"""
|
|
return await self.elicit_form(message, requested_schema, related_request_id)
|
|
|
|
async def elicit_form(
|
|
self,
|
|
message: str,
|
|
requested_schema: types.ElicitRequestedSchema,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.ElicitResult:
|
|
"""Send a form mode elicitation/create request.
|
|
|
|
Args:
|
|
message: The message to present to the user.
|
|
requested_schema: Schema defining the expected response structure.
|
|
related_request_id: Optional ID of the request that triggered this elicitation.
|
|
|
|
Returns:
|
|
The client's response with form data.
|
|
|
|
Raises:
|
|
NoBackChannelError: The connection has no back-channel for
|
|
server-initiated requests.
|
|
"""
|
|
return await self.send_request(
|
|
types.ElicitRequest(
|
|
params=types.ElicitRequestFormParams(
|
|
message=message,
|
|
requested_schema=requested_schema,
|
|
),
|
|
),
|
|
types.ElicitResult,
|
|
metadata=ServerMessageMetadata(related_request_id=related_request_id),
|
|
)
|
|
|
|
async def elicit_url(
|
|
self,
|
|
message: str,
|
|
url: str,
|
|
elicitation_id: str,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> types.ElicitResult:
|
|
"""Send a URL mode elicitation/create request.
|
|
|
|
This directs the user to an external URL for out-of-band interactions
|
|
like OAuth flows, credential collection, or payment processing.
|
|
|
|
Args:
|
|
message: Human-readable explanation of why the interaction is needed.
|
|
url: The URL the user should navigate to.
|
|
elicitation_id: Unique identifier for tracking this elicitation.
|
|
related_request_id: Optional ID of the request that triggered this elicitation.
|
|
|
|
Returns:
|
|
The client's response indicating acceptance, decline, or cancellation.
|
|
|
|
Raises:
|
|
NoBackChannelError: The connection has no back-channel for
|
|
server-initiated requests.
|
|
"""
|
|
return await self.send_request(
|
|
types.ElicitRequest(
|
|
params=types.ElicitRequestURLParams(
|
|
message=message,
|
|
url=url,
|
|
elicitation_id=elicitation_id,
|
|
),
|
|
),
|
|
types.ElicitResult,
|
|
metadata=ServerMessageMetadata(related_request_id=related_request_id),
|
|
)
|
|
|
|
async def send_ping(self) -> types.EmptyResult:
|
|
"""Send a ping request."""
|
|
return await self.send_request(
|
|
types.PingRequest(),
|
|
types.EmptyResult,
|
|
)
|
|
|
|
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
|
"""Report progress for the inbound request this session is scoped to.
|
|
|
|
A no-op when the caller did not request progress. Dispatcher-agnostic:
|
|
on JSON-RPC the held `DispatchContext` emits ``notifications/progress``
|
|
against the caller's token; on the in-process direct dispatcher it
|
|
invokes the caller's callback directly.
|
|
"""
|
|
await self._request_outbound.progress(progress, total, message)
|
|
|
|
async def send_progress_notification(
|
|
self,
|
|
progress_token: str | int,
|
|
progress: float,
|
|
total: float | None = None,
|
|
message: str | None = None,
|
|
related_request_id: str | None = None,
|
|
) -> None:
|
|
"""Send a progress notification."""
|
|
await self.send_notification(
|
|
types.ProgressNotification(
|
|
params=types.ProgressNotificationParams(
|
|
progress_token=progress_token,
|
|
progress=progress,
|
|
total=total,
|
|
message=message,
|
|
),
|
|
),
|
|
related_request_id,
|
|
)
|
|
|
|
async def send_resource_list_changed(self) -> None:
|
|
"""Send a resource list changed notification."""
|
|
await self.send_notification(types.ResourceListChangedNotification())
|
|
|
|
async def send_tool_list_changed(self) -> None:
|
|
"""Send a tool list changed notification."""
|
|
await self.send_notification(types.ToolListChangedNotification())
|
|
|
|
async def send_prompt_list_changed(self) -> None:
|
|
"""Send a prompt list changed notification."""
|
|
await self.send_notification(types.PromptListChangedNotification())
|
|
|
|
async def send_elicit_complete(
|
|
self,
|
|
elicitation_id: str,
|
|
related_request_id: types.RequestId | None = None,
|
|
) -> None:
|
|
"""Send an elicitation completion notification.
|
|
|
|
This should be sent when a URL mode elicitation has been completed
|
|
out-of-band to inform the client that it may retry any requests
|
|
that were waiting for this elicitation.
|
|
|
|
Args:
|
|
elicitation_id: The unique identifier of the completed elicitation
|
|
related_request_id: Optional ID of the request that triggered this notification
|
|
"""
|
|
await self.send_notification(
|
|
types.ElicitCompleteNotification(
|
|
params=types.ElicitCompleteNotificationParams(elicitation_id=elicitation_id)
|
|
),
|
|
related_request_id,
|
|
)
|