diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md index ad2f4c63fd..3ba3fab26d 100644 --- a/docs/get-started/first-steps.md +++ b/docs/get-started/first-steps.md @@ -46,10 +46,6 @@ Three plain functions, three decorators. Each decorator is the entire registrati Everything else (the name, the description, the argument schema) the SDK reads from the function itself: its name, its docstring, its type hints. You never declared any of it separately. -!!! tip - The two halves of the SDK have two import paths: `from mcp import Client` and - `from mcp.server import MCPServer`. There is no `from mcp import MCPServer`. - ### Try it Run it with the MCP Inspector: diff --git a/docs/migration.md b/docs/migration.md index 7f862c2401..defd7950ee 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -265,6 +265,7 @@ The following type aliases and classes have been removed from the protocol types | `AnyFunction` | Use `Callable[..., Any]` directly | | `ClientRequestType`, `ClientNotificationType`, `ClientResultType`, `ServerRequestType`, `ServerNotificationType`, `ServerResultType` | The union is now the bare name: `ClientRequest`, `ClientNotification`, `ClientResult`, `ServerRequest`, `ServerNotification`, `ServerResult` | | `TaskExecutionMode`, `TASK_FORBIDDEN`, `TASK_OPTIONAL`, `TASK_REQUIRED`, `TASK_STATUS_*` | Use string literals; `TaskStatus` remains as the literal-union type | +| `SamplingRole` (a top-level `mcp` alias for `Role`, never a `mcp.types` name) | `mcp.types.Role` | **Before (v1):** @@ -657,6 +658,8 @@ from mcp.server.mcpserver import MCPServer, Context mcp = MCPServer("Demo") ``` +`MCPServer` is also exported from the top-level `mcp` package, next to `Client` (`from mcp import MCPServer, Client`); `mcp.server` and `mcp.server.mcpserver` keep working as import paths. + `Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). The `ctx.fastmcp` property is now `ctx.mcp_server`. All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: @@ -2166,6 +2169,12 @@ The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been re `sse_client` is unchanged apart from the `httpx2` retyping: it still takes `url`, `headers`, `timeout`, `sse_read_timeout`, `httpx_client_factory` (which must now return an `httpx2.AsyncClient`), `auth` (now `httpx2.Auth | None`), and `on_session_created`. Only the streamable HTTP transport dropped its transport-level parameters; `StreamableHTTPTransport(url)` now takes just the URL. +### `StreamableHTTPError`, `ResumptionError`, and `StreamableHTTPTransport.get_session_id()` removed + +`mcp.client.streamable_http` no longer defines `StreamableHTTPError` or its `ResumptionError` subclass: the transport never raised either from a code path a caller could catch, so `from mcp.client.streamable_http import StreamableHTTPError, ResumptionError` now raises `ImportError`. Transport failures surface as per-request JSON-RPC errors (see [Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors](#streamable-http-non-2xx-responses-now-surface-as-per-request-json-rpc-errors)) or as the underlying `httpx2` exception; catch those instead. + +The unused `StreamableHTTPTransport.get_session_id()` method is gone as well; read the `session_id` attribute directly (or capture the header, as in [`get_session_id` callback removed](#get_session_id-callback-removed-from-streamable_http_client) above). + ### `StreamableHTTPTransport.protocol_version` attribute removed The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read `session.protocol_version` (or `client.protocol_version` on the high-level `Client`) instead. @@ -2771,7 +2780,7 @@ The user-facing methods for these features now carry `typing_extensions.deprecat - Sampling: `ServerSession.create_message()`, `ClientPeer.sample()` - Roots: `ServerSession.list_roots()`, `ClientPeer.list_roots()`, `ClientSession.send_roots_list_changed()`, `Client.send_roots_list_changed()` -- Logging: `ServerSession.send_log_message()`, `Connection.log()`, `ClientSession.set_logging_level()`, `Client.set_logging_level()`, `mcp.server.context.Context.log()` (the lowlevel `Context`), and the `MCPServer` `Context` helpers `log()`, `debug()`, `info()`, `warning()`, `error()` +- Logging: `ServerSession.send_log_message()`, `Connection.log()`, `ClientSession.set_logging_level()`, `Client.set_logging_level()`, and the `MCPServer` `Context` helpers `log()`, `debug()`, `info()`, `warning()`, `error()` Registering a handler for a deprecated capability is deprecated too. The `Server.__init__` parameters `on_set_logging_level` (Logging) and `on_roots_list_changed` (Roots) are now split out into a `typing_extensions.deprecated` overload, so passing either is flagged by type checkers and emits `mcp.MCPDeprecationWarning` at construction time. `on_progress` follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free. @@ -2804,7 +2813,7 @@ rest of this guide stays focused on the v1-to-v2 upgrade itself. The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client. -`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request). +`NoBackChannelError` is exported from the top-level `mcp` package (`from mcp import NoBackChannelError`; it lives in `mcp.shared.exceptions`) and subclasses `MCPError` (code `-32600`, message `Cannot send '': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request). Two ways to migrate: diff --git a/docs/whats-new.md b/docs/whats-new.md index 3107d2e8aa..adc3d8465c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -24,6 +24,8 @@ from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP mcp = MCPServer("Demo") # v1: FastMCP("Demo") ``` +Both halves of the SDK are also on the top-level package, so `from mcp import MCPServer, Client` works. + It is also, for a decorator-built server, most of the port. `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` accept what they accepted in v1 (`@mcp.resource()` adds one optional `security=` keyword), and the input schema still comes from your type hints. Around the edges: everything under `mcp.server.fastmcp.*` now lives under `mcp.server.mcpserver.*`, `ctx.fastmcp` is `ctx.mcp_server`, `get_context()` is gone (declare a `ctx: Context` parameter instead), and the exception base `FastMCPError` is `MCPServerError`. The **[Migration Guide](migration.md#fastmcp-renamed-to-mcpserver)** has the import table. ### `Resolve`: the new way to ask the user for input diff --git a/docs_src/subscriptions/tutorial005.py b/docs_src/subscriptions/tutorial005.py index 17387fab17..8d066800e0 100644 --- a/docs_src/subscriptions/tutorial005.py +++ b/docs_src/subscriptions/tutorial005.py @@ -1,7 +1,7 @@ import anyio from mcp import Client -from mcp.client.subscriptions import SubscriptionLost +from mcp.client import SubscriptionLost from .tutorial003 import read_board diff --git a/pyproject.toml b/pyproject.toml index a18d6e98ee..4b13d5b33b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,6 +203,7 @@ select = [ "F", # pyflakes "I", # isort "PERF", # Perflint + "RUF022", # unsorted-dunder-all: keep `__all__` sorted "UP", # pyupgrade "TID251", # https://docs.astral.sh/ruff/rules/banned-api/ ] @@ -219,6 +220,8 @@ max-complexity = 24 # Default is 10 "__init__.py" = ["F401"] # The mcp.types package is an alias that mirrors mcp_types namespaces by design. "src/mcp/types/*.py" = ["F403"] +# mcp_types' __all__ is grouped by section comments (capabilities, content types, requests, ...) rather than sorted. +"src/mcp-types/mcp_types/__init__.py" = ["RUF022"] # Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators). "src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"] "tests/server/mcpserver/test_func_metadata.py" = ["E501"] diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 41959c56d7..fdcf443e08 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -27,7 +27,6 @@ "CLIENT_NOTIFICATIONS", "CLIENT_REQUESTS", "CLIENT_RESULTS", - "CacheableMethod", "INPUT_REQUIRED_METHODS", "MONOLITH_NOTIFICATIONS", "MONOLITH_REQUESTS", @@ -37,6 +36,7 @@ "SERVER_RESULTS", "SPEC_CLIENT_METHODS", "SPEC_CLIENT_NOTIFICATION_METHODS", + "CacheableMethod", "is_input_required", "parse_client_notification", "parse_client_request", diff --git a/src/mcp-types/mcp_types/version.py b/src/mcp-types/mcp_types/version.py index e2f7e1ac86..d47f46ccea 100644 --- a/src/mcp-types/mcp_types/version.py +++ b/src/mcp-types/mcp_types/version.py @@ -10,14 +10,14 @@ from typing import Final __all__ = [ - "KNOWN_PROTOCOL_VERSIONS", "HANDSHAKE_PROTOCOL_VERSIONS", - "MODERN_PROTOCOL_VERSIONS", - "SUPPORTED_PROTOCOL_VERSIONS", - "LATEST_PROTOCOL_VERSION", + "KNOWN_PROTOCOL_VERSIONS", "LATEST_HANDSHAKE_VERSION", "LATEST_MODERN_VERSION", + "LATEST_PROTOCOL_VERSION", + "MODERN_PROTOCOL_VERSIONS", "OLDEST_SUPPORTED_VERSION", + "SUPPORTED_PROTOCOL_VERSIONS", "is_version_at_least", ] diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 28bc4703ed..1864367aa0 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -56,7 +56,6 @@ ToolUseContent, UnsubscribeRequest, ) -from mcp_types import Role as SamplingRole # Bind the `mcp.types` submodule on the package, as v1's `from .types import # ...` did, so `import mcp` followed by `mcp.types.Tool` keeps working. @@ -66,9 +65,10 @@ from .client.session import ClientSession from .client.session_group import ClientSessionGroup from .client.stdio import StdioServerParameters, stdio_client +from .server.mcpserver import MCPServer from .server.session import ServerSession from .server.stdio import stdio_server -from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError +from .shared.exceptions import MCPDeprecationWarning, MCPError, NoBackChannelError, UrlElicitationRequiredError from .shared.uri_template import InvalidUriTemplate, UriTemplate __all__ = [ @@ -93,6 +93,7 @@ "InitializeResult", "InitializedNotification", "InputRequiredRoundsExceededError", + "InvalidUriTemplate", "JSONRPCError", "JSONRPCRequest", "JSONRPCResponse", @@ -105,6 +106,8 @@ "LoggingMessageNotification", "MCPDeprecationWarning", "MCPError", + "MCPServer", + "NoBackChannelError", "Notification", "PingRequest", "ProgressNotification", @@ -112,15 +115,14 @@ "ReadResourceRequest", "ReadResourceResult", "Resource", - "ResourcesCapability", "ResourceUpdatedNotification", + "ResourcesCapability", "RootsCapability", "SamplingCapability", "SamplingContent", "SamplingContextCapability", "SamplingMessage", "SamplingMessageContentBlock", - "SamplingRole", "SamplingToolsCapability", "ServerCapabilities", "ServerNotification", @@ -134,12 +136,11 @@ "Tool", "ToolChoice", "ToolResultContent", - "ToolsCapability", "ToolUseContent", + "ToolsCapability", "UnsubscribeRequest", "UriTemplate", "UrlElicitationRequiredError", - "InvalidUriTemplate", "stdio_client", "stdio_server", ] diff --git a/src/mcp/cli/__init__.py b/src/mcp/cli/__init__.py index daf07c0a13..f4611b93bb 100644 --- a/src/mcp/cli/__init__.py +++ b/src/mcp/cli/__init__.py @@ -2,5 +2,4 @@ from .cli import app -if __name__ == "__main__": # pragma: no cover - app() +__all__ = ["app"] diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py index d6b07045ce..00d83dad71 100644 --- a/src/mcp/client/__init__.py +++ b/src/mcp/client/__init__.py @@ -21,6 +21,7 @@ advertise, ) from mcp.client.session import ClientSession, IncomingMessage +from mcp.client.subscriptions import ListenNotSupportedError, Subscription, SubscriptionLost __all__ = [ "CacheConfig", @@ -32,12 +33,15 @@ "ClientExtension", "ClientRequestContext", "ClientSession", - "IncomingMessage", "InMemoryResponseCacheStore", + "IncomingMessage", "InputRequiredRoundsExceededError", + "ListenNotSupportedError", "NotificationBinding", "ResponseCacheStore", "ResultClaim", + "Subscription", + "SubscriptionLost", "Transport", "UnexpectedClaimedResult", "advertise", diff --git a/src/mcp/client/_transport.py b/src/mcp/client/_transport.py index 0163fef950..73e6f9ae8e 100644 --- a/src/mcp/client/_transport.py +++ b/src/mcp/client/_transport.py @@ -8,7 +8,7 @@ from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.message import SessionMessage -__all__ = ["ReadStream", "WriteStream", "Transport", "TransportStreams"] +__all__ = ["ReadStream", "Transport", "TransportStreams", "WriteStream"] TransportStreams = tuple[ReadStream[SessionMessage | Exception], WriteStream[SessionMessage]] diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index c95cfcf50b..f409c32b48 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -54,14 +54,6 @@ MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up -class StreamableHTTPError(Exception): - """Base exception for StreamableHTTP transport errors.""" - - -class ResumptionError(StreamableHTTPError): - """Raised when resumption request is invalid.""" - - @dataclass class RequestContext: """Context for a request operation.""" @@ -240,13 +232,10 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer logger.info(f"GET stream disconnected, reconnecting in {delay_ms}ms...") await anyio.sleep(delay_ms / 1000.0) - async def _handle_resumption_request(self, ctx: RequestContext) -> None: + async def _handle_resumption_request(self, ctx: RequestContext, resumption_token: str) -> None: """Handle a resumption request using GET with SSE.""" headers = self._prepare_headers() - if ctx.metadata and ctx.metadata.resumption_token: - headers[LAST_EVENT_ID] = ctx.metadata.resumption_token - else: - raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover + headers[LAST_EVENT_ID] = resumption_token # Extract original request ID to map responses original_request_id = None @@ -556,8 +545,8 @@ async def _handle_message(session_message: SessionMessage) -> None: else None ) - # Check if this is a resumption request - is_resumption = bool(metadata and metadata.resumption_token) + # A resumption token routes this send to a GET+Last-Event-ID replay + resumption_token = metadata.resumption_token if metadata else None logger.debug(f"Sending client message: {message}") @@ -583,8 +572,8 @@ async def _handle_message(session_message: SessionMessage) -> None: ) async def handle_request_async(): - if is_resumption: - await self._handle_resumption_request(ctx) + if resumption_token: + await self._handle_resumption_request(ctx, resumption_token) else: await self._handle_post_request(ctx) @@ -635,15 +624,7 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None: except Exception as exc: # pragma: no cover logger.warning(f"Session termination failed: {exc}") - # TODO(Marcelo): Check the TODO below, and cover this with tests if necessary. - def get_session_id(self) -> str | None: - """Get the current session ID.""" - return self.session_id # pragma: no cover - -# TODO(Marcelo): I've dropped the `get_session_id` callback because it breaks the Transport protocol. Is that needed? -# It's a completely wrong abstraction, so removal is a good idea. But if we need the client to find the session ID, -# we should think about a better way to do it. I believe we can achieve it with other means. @asynccontextmanager async def streamable_http_client( url: str, diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index 5897e8aa8b..1bf86024ba 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -4,4 +4,4 @@ from .mcpserver import MCPServer from .models import InitializationOptions -__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] +__all__ = ["CacheHint", "InitializationOptions", "MCPServer", "NotificationOptions", "Server", "ServerRequestContext"] diff --git a/src/mcp/server/_context.py b/src/mcp/server/_context.py new file mode 100644 index 0000000000..0329c35b88 --- /dev/null +++ b/src/mcp/server/_context.py @@ -0,0 +1,97 @@ +import logging +from collections.abc import Mapping +from typing import Any, Generic + +from mcp_types import LoggingLevel, RequestParamsMeta +from typing_extensions import TypeVar, deprecated + +from mcp.server.connection import Connection, allowed_log_levels +from mcp.shared.context import BaseContext +from mcp.shared.dispatcher import DispatchContext +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.peer import Meta +from mcp.shared.transport_context import TransportContext + +logger = logging.getLogger(__name__) +# `Context.log`'s `logger` parameter (the spec's logger-name field) shadows +# the module logger inside that method; this alias keeps it reachable there. +_logger = logger + +# Covariant: `lifespan` is exposed read-only, so a `Context[AppState]` passes as `Context[object]`. +LifespanT_co = TypeVar("LifespanT_co", default=Any, covariant=True) + + +class Context(BaseContext[TransportContext], Generic[LifespanT_co]): + """Server-side per-request context. + + Extends `BaseContext` (transport metadata, the raw back-channel, progress + reporting) with `lifespan`, `connection`, and request-scoped `log`. + + Private for now: `ServerRunner` does not construct it, and hands handlers + a `ServerRequestContext` (in `mcp.server.context`) instead. + """ + + def __init__( + self, + dctx: DispatchContext[TransportContext], + *, + lifespan: LifespanT_co, + connection: Connection, + meta: RequestParamsMeta | None = None, + ) -> None: + super().__init__(dctx, meta=meta) + self._lifespan = lifespan + self._connection = connection + # Same per-request log gate as `ServerSession`: fixed at construction + # from this request's `_meta` log-level opt-in and the connection's era. + self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta) + + @property + def lifespan(self) -> LifespanT_co: + """The server-wide lifespan output (what `Server(..., lifespan=...)` yielded).""" + return self._lifespan + + @property + def connection(self) -> Connection: + """The per-client `Connection` for this request's connection.""" + return self._connection + + @property + def session_id(self) -> str | None: + """The transport's session id for this connection, when one exists. + + Convenience for `ctx.connection.session_id`. `None` on stdio and + stateless HTTP. + """ + return self._connection.session_id + + @property + def headers(self) -> Mapping[str, str] | None: + """Request headers carried by this message, when the transport has them. + + Convenience for `ctx.transport.headers`. `None` on stdio. + """ + return self.transport.headers + + @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None: + """Send a request-scoped `notifications/message` log entry. + + Uses this request's back-channel (so the entry rides the request's SSE + stream in streamable HTTP), not the standalone stream - use + `ctx.connection.log(...)` for that. + + 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). + Handshake versions send unconditionally, 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 + params: dict[str, Any] = {"level": level, "data": data} + if logger is not None: + params["logger"] = logger + if meta: + params["_meta"] = meta + await self.notify("notifications/message", params) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 7fb14b2c43..91e8b6601f 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -14,10 +14,6 @@ from mcp.server.auth.settings import ClientRegistrationOptions from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthClientMetadata -# this alias is a no-op; it's just to separate out the types exposed to the -# provider from what we use in the HTTP handler -RegistrationRequest = OAuthClientMetadata - class RegistrationErrorResponse(BaseModel): error: RegistrationErrorCode diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index 0e644c378a..64aafbd3e5 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -74,19 +74,13 @@ class TokenErrorResponse(BaseModel): error_uri: AnyHttpUrl | None = None -# this is just an alias over OAuthToken; the only reason we do this -# is to have some separation between the HTTP response type, and the -# type returned by the provider -TokenSuccessResponse = OAuthToken - - @dataclass class TokenHandler: provider: OAuthAuthorizationServerProvider[Any, Any, Any] client_authenticator: ClientAuthenticator identity_assertion_enabled: bool = False - def response(self, obj: TokenSuccessResponse | TokenErrorResponse): + def response(self, obj: OAuthToken | TokenErrorResponse): status_code = 200 if isinstance(obj, TokenErrorResponse): status_code = 400 diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py index bfcb9c9ca4..180f11ebb3 100644 --- a/src/mcp/server/context.py +++ b/src/mcp/server/context.py @@ -1,26 +1,13 @@ -import logging from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any, Generic, Protocol -from mcp_types import LoggingLevel, RequestId, RequestParamsMeta +from mcp_types import RequestId, RequestParamsMeta from pydantic import BaseModel -from typing_extensions import TypeVar, deprecated +from typing_extensions import TypeVar -from mcp.server.connection import Connection, allowed_log_levels from mcp.server.session import ServerSession -from mcp.shared.context import BaseContext -from mcp.shared.dispatcher import DispatchContext -from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.message import CloseSSEStreamCallback -from mcp.shared.peer import Meta -from mcp.shared.transport_context import TransportContext - -logger = logging.getLogger(__name__) -# `Context.log`'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 # Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan. LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any]) @@ -49,86 +36,6 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]): close_standalone_sse_stream: CloseSSEStreamCallback | None = None -# Covariant: `lifespan` is exposed read-only, so a `Context[AppState]` passes as `Context[object]`. -LifespanT_co = TypeVar("LifespanT_co", default=Any, covariant=True) - - -class Context(BaseContext[TransportContext], Generic[LifespanT_co]): - """Server-side per-request context. - - Extends `BaseContext` (transport metadata, the raw back-channel, progress - reporting) with `lifespan`, `connection`, and request-scoped `log`. - - Not currently constructed by `ServerRunner`, which hands handlers a - `ServerRequestContext` instead. - """ - - def __init__( - self, - dctx: DispatchContext[TransportContext], - *, - lifespan: LifespanT_co, - connection: Connection, - meta: RequestParamsMeta | None = None, - ) -> None: - super().__init__(dctx, meta=meta) - self._lifespan = lifespan - self._connection = connection - # Same per-request log gate as `ServerSession`: fixed at construction - # from this request's `_meta` log-level opt-in and the connection's era. - self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta) - - @property - def lifespan(self) -> LifespanT_co: - """The server-wide lifespan output (what `Server(..., lifespan=...)` yielded).""" - return self._lifespan - - @property - def connection(self) -> Connection: - """The per-client `Connection` for this request's connection.""" - return self._connection - - @property - def session_id(self) -> str | None: - """The transport's session id for this connection, when one exists. - - Convenience for `ctx.connection.session_id`. `None` on stdio and - stateless HTTP. - """ - return self._connection.session_id - - @property - def headers(self) -> Mapping[str, str] | None: - """Request headers carried by this message, when the transport has them. - - Convenience for `ctx.transport.headers`. `None` on stdio. - """ - return self.transport.headers - - @deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) - async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None: - """Send a request-scoped `notifications/message` log entry. - - Uses this request's back-channel (so the entry rides the request's SSE - stream in streamable HTTP), not the standalone stream - use - `ctx.connection.log(...)` for that. - - 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). - Handshake versions send unconditionally, 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 - params: dict[str, Any] = {"level": level, "data": data} - if logger is not None: - params["logger"] = logger - if meta: - params["_meta"] = meta - await self.notify("notifications/message", params) - - HandlerResult = BaseModel | dict[str, Any] | None """What a request handler (or middleware) may return. `ServerRunner` serializes all three to a result dict.""" @@ -182,10 +89,11 @@ class ServerMiddleware(Protocol[_MwLifespanT]): any `Server[L]`. """ - # TODO(maxisbey): once `_make_context` returns the (covariant) `Context[L]` - # again, restore `_MwLifespanT` to `contravariant=True` and retype `ctx` - # below to `Context[_MwLifespanT]` so reusable middleware can be - # `ServerMiddleware[object]` instead of `ServerMiddleware[Any]`. + # TODO(maxisbey): once `_make_context` returns the (covariant) + # `mcp.server._context.Context[L]` again, restore `_MwLifespanT` to + # `contravariant=True` and retype `ctx` below to `Context[_MwLifespanT]` so + # reusable middleware can be `ServerMiddleware[object]` instead of + # `ServerMiddleware[Any]`. async def __call__( self, diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py index 6205a7033d..798e20f71e 100644 --- a/src/mcp/server/extension.py +++ b/src/mcp/server/extension.py @@ -28,9 +28,7 @@ from pydantic import BaseModel from mcp.server.context import CallNext, HandlerResult, ServerRequestContext - -# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers. -from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier +from mcp.shared.extension import validate_extension_identifier if TYPE_CHECKING: from mcp.server.mcpserver.resources import Resource diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 56d1c23cba..1d1e555318 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -28,30 +28,30 @@ from .utilities.types import Audio, Image __all__ = [ - "MCPServer", - "Context", - "Image", + "DEFAULT_RESOURCE_SECURITY", + "AESGCMRequestStateCodec", + "AcceptedElicitation", "Audio", - "Icon", - "Resolve", + "CancelledElicitation", + "Context", + "DeclinedElicitation", "Elicit", - "Sample", - "ListRoots", "ElicitationResult", - "AcceptedElicitation", - "DeclinedElicitation", - "CancelledElicitation", "Extension", - "ToolBinding", - "ResourceBinding", + "Icon", + "Image", + "InvalidRequestState", + "ListRoots", + "MCPServer", "MethodBinding", - "require_client_extension", - "ResourceSecurity", - "DEFAULT_RESOURCE_SECURITY", - "RequestStateSecurity", - "RequestStateCodec", "RequestStateBoundary", - "AESGCMRequestStateCodec", - "InvalidRequestState", + "RequestStateCodec", + "RequestStateSecurity", + "Resolve", + "ResourceBinding", + "ResourceSecurity", + "Sample", + "ToolBinding", "authenticated_principal", + "require_client_extension", ] diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index d4a744af37..350752df00 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -823,16 +823,16 @@ def _restore_outcome(res: _Resolution, key: str, marker: _Marker, q: str) -> Eli __all__ = [ - "Resolve", - "Elicit", - "Sample", - "ListRoots", - "ElicitationResult", "AcceptedElicitation", - "DeclinedElicitation", "CancelledElicitation", - "find_resolved_parameters", + "DeclinedElicitation", + "Elicit", + "ElicitationResult", + "ListRoots", + "Resolve", + "Sample", "build_resolver_plans", + "find_resolved_parameters", "resolve_arguments", "returns_input_required", ] diff --git a/src/mcp/server/mcpserver/resources/__init__.py b/src/mcp/server/mcpserver/resources/__init__.py index f54ea44e42..5083f532f4 100644 --- a/src/mcp/server/mcpserver/resources/__init__.py +++ b/src/mcp/server/mcpserver/resources/__init__.py @@ -11,16 +11,16 @@ ) __all__ = [ - "Resource", - "TextResource", + "DEFAULT_RESOURCE_SECURITY", "BinaryResource", - "FunctionResource", + "DirectoryResource", "FileResource", + "FunctionResource", "HttpResource", - "DirectoryResource", - "ResourceTemplate", + "Resource", "ResourceManager", "ResourceSecurity", "ResourceSecurityError", - "DEFAULT_RESOURCE_SECURITY", + "ResourceTemplate", + "TextResource", ] diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 6bb638886a..83574e0060 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -4,7 +4,7 @@ import httpx2 -__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] +__all__ = ["MCP_DEFAULT_SSE_READ_TIMEOUT", "MCP_DEFAULT_TIMEOUT", "create_mcp_http_client"] # Default MCP timeout configuration MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds) diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index c28aa7fb71..da16502ef6 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -38,14 +38,14 @@ __all__ = [ "ERROR_CODE_HTTP_STATUS", - "InboundLadderRejection", - "InboundModernRoute", "MCP_METHOD_HEADER", "MCP_NAME_HEADER", "MCP_PARAM_HEADER_PREFIX", "MCP_PROTOCOL_VERSION_HEADER", "NAME_BEARING_METHODS", "X_MCP_HEADER_KEY", + "InboundLadderRejection", + "InboundModernRoute", "classify_inbound_request", "decode_header_value", "encode_header_value", diff --git a/src/mcp/shared/uri_template.py b/src/mcp/shared/uri_template.py index 20d6fa9c2e..dd1ffe8fa9 100644 --- a/src/mcp/shared/uri_template.py +++ b/src/mcp/shared/uri_template.py @@ -53,8 +53,8 @@ __all__ = [ "DEFAULT_MAX_TEMPLATE_LENGTH", - "DEFAULT_MAX_VARIABLES", "DEFAULT_MAX_URI_LENGTH", + "DEFAULT_MAX_VARIABLES", "InvalidUriTemplate", "Operator", "UriTemplate", diff --git a/tests/server/test_server_context.py b/tests/server/test_server_context.py index 9907e18013..11dee451e2 100644 --- a/tests/server/test_server_context.py +++ b/tests/server/test_server_context.py @@ -14,8 +14,8 @@ from mcp_types import LOG_LEVEL_META_KEY from mcp_types.version import LATEST_MODERN_VERSION +from mcp.server._context import Context from mcp.server.connection import Connection -from mcp.server.context import Context from mcp.shared.dispatcher import DispatchContext from mcp.shared.transport_context import TransportContext diff --git a/tests/shared/test_extension.py b/tests/shared/test_extension.py index fd9192554e..f0a91b78d7 100644 --- a/tests/shared/test_extension.py +++ b/tests/shared/test_extension.py @@ -4,16 +4,9 @@ import pytest -import mcp.server.extension -import mcp.shared.extension from mcp.shared.extension import validate_extension_identifier -def test_server_extension_module_reexports_shared_validator() -> None: - """SDK-defined: `mcp.server.extension` re-exports the shared validator as the same function object.""" - assert mcp.server.extension.validate_extension_identifier is mcp.shared.extension.validate_extension_identifier - - @pytest.mark.parametrize( "identifier", [