From adda3464a8628d1a7cf78eccd55e602479a94b2d Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:50:07 +0000 Subject: [PATCH] Deprecate the WebSocket transport and the experimental tasks entry points Both surfaces are removed in mcp 2.0, so mark them deprecated on the v1.x line to give users advance notice: - websocket_client and websocket_server now carry typing_extensions.deprecated markers and emit a DeprecationWarning when called. WebSocket was never part of the MCP specification; the streamable HTTP transport is the replacement. - The experimental tasks entry points (ClientSession.experimental, the experimental_task_handlers kwarg, ServerSession.experimental, and Server.experimental) warn the same way. Tasks (SEP-1686) were removed from the MCP specification and are expected to return as a separate MCP extension. Warnings are emitted only when the deprecated APIs are used: there are no import-time warnings, and internal code paths now go through a private ServerSession._experimental accessor so plain (non-tasks) clients and servers never see a warning. A regression test asserts that plain session usage stays warning-free, and the warning ignores for the suites that intentionally exercise these APIs are scoped to those suites rather than silenced globally. The docs pages for the experimental tasks API now carry a deprecation banner instead of the experimental one. --- docs/experimental/index.md | 15 ++-- docs/experimental/tasks-client.md | 6 +- docs/experimental/tasks-server.md | 6 +- docs/experimental/tasks.md | 7 +- pyproject.toml | 6 ++ src/mcp/client/session.py | 20 +++++- src/mcp/client/websocket.py | 8 +++ .../server/experimental/request_context.py | 3 +- src/mcp/server/experimental/task_context.py | 14 ++-- src/mcp/server/experimental/task_support.py | 6 +- src/mcp/server/lowlevel/experimental.py | 3 +- src/mcp/server/lowlevel/server.py | 10 ++- src/mcp/server/session.py | 20 ++++-- src/mcp/server/websocket.py | 10 ++- tests/experimental/tasks/conftest.py | 18 +++++ tests/experimental/tasks/test_deprecations.py | 72 +++++++++++++++++++ .../tasks/test_request_context.py | 2 +- tests/shared/test_ws.py | 25 +++++++ 18 files changed, 219 insertions(+), 32 deletions(-) create mode 100644 tests/experimental/tasks/conftest.py create mode 100644 tests/experimental/tasks/test_deprecations.py diff --git a/docs/experimental/index.md b/docs/experimental/index.md index 1d496b3f10..3b7f113ad7 100644 --- a/docs/experimental/index.md +++ b/docs/experimental/index.md @@ -1,12 +1,13 @@ # Experimental Features -!!! warning "Experimental APIs" +!!! warning "Deprecated" - The features in this section are experimental and may change without notice. - They track the evolving MCP specification and are not yet stable. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. This section documents experimental features in the MCP Python SDK. These features -implement draft specifications that are still being refined. +are deprecated and remain available on the 1.x line only for existing users. ## Available Experimental Features @@ -36,8 +37,10 @@ async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) ``` +Accessing the `.experimental` properties emits a `DeprecationWarning`. + ## Providing Feedback -Since these features are experimental, feedback is especially valuable. If you encounter -issues or have suggestions, please open an issue on the +If you rely on these features and have feedback on their deprecation or the planned +MCP extension, please open an issue on the [python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues). diff --git a/docs/experimental/tasks-client.md b/docs/experimental/tasks-client.md index cfd23e4e14..acc8392026 100644 --- a/docs/experimental/tasks-client.md +++ b/docs/experimental/tasks-client.md @@ -1,8 +1,10 @@ # Client Task Usage -!!! warning "Experimental" +!!! warning "Deprecated" - Tasks are an experimental feature. The API may change without notice. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers. diff --git a/docs/experimental/tasks-server.md b/docs/experimental/tasks-server.md index c6b94814cd..1760e197be 100644 --- a/docs/experimental/tasks-server.md +++ b/docs/experimental/tasks-server.md @@ -1,8 +1,10 @@ # Server Task Implementation -!!! warning "Experimental" +!!! warning "Deprecated" - Tasks are an experimental feature. The API may change without notice. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks. diff --git a/docs/experimental/tasks.md b/docs/experimental/tasks.md index 2d4d06a025..e5dd78fb20 100644 --- a/docs/experimental/tasks.md +++ b/docs/experimental/tasks.md @@ -1,9 +1,10 @@ # Tasks -!!! warning "Experimental" +!!! warning "Deprecated" - Tasks are an experimental feature tracking the draft MCP specification. - The API may change without notice. + The experimental tasks API is deprecated and will be removed in mcp 2.0. + Tasks (SEP-1686) were removed from the MCP specification and are expected + to return as a separate MCP extension in a future release. Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result. diff --git a/pyproject.toml b/pyproject.toml index 2f5906985c..769ddcf709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,13 @@ venv = ".venv" # those private functions instead of testing the private functions directly. It makes it easier to maintain the code source # and refactor code that is not public. executionEnvironments = [ + # The experimental tasks API and the WebSocket transport are deprecated; the suites and + # examples that cover them intentionally use the deprecated APIs. + { root = "tests/experimental", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false, reportDeprecated = false }, + { root = "tests/shared/test_ws.py", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false, reportDeprecated = false }, { root = "tests", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "examples/servers/simple-task", reportUnusedFunction = false, reportDeprecated = false }, + { root = "examples/servers/simple-task-interactive", reportUnusedFunction = false, reportDeprecated = false }, { root = "examples/servers", reportUnusedFunction = false }, { root = "examples/snippets/clients/logging_client.py", reportUndefinedVariable = false, reportUnknownArgumentType = false }, { root = "examples/snippets/clients/roots_example.py", reportUndefinedVariable = false, reportUnknownArgumentType = false, reportArgumentType = false }, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 8519f15cec..86f2676dcb 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,4 +1,5 @@ import logging +import warnings from datetime import timedelta from typing import Any, Protocol, overload @@ -17,6 +18,15 @@ DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") +# Type checkers only surface `@deprecated` messages given as string literals, so the same text is +# repeated inline at each deprecated entry point (here, server/session.py, server/lowlevel/server.py). +# Keep those copies, the filterwarnings marks in the tasks test suites, and the pytest.warns match in +# tests/experimental/tasks/test_deprecations.py prefix-aligned when rewording. +_EXPERIMENTAL_TASKS_DEPRECATION = ( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." +) + logger = logging.getLogger("client") @@ -143,6 +153,8 @@ def __init__( self._experimental_features: ExperimentalClientFeatures | None = None # Experimental: Task handlers (use defaults if not provided) + if experimental_task_handlers is not None: + warnings.warn(_EXPERIMENTAL_TASKS_DEPRECATION, DeprecationWarning, stacklevel=2) self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers() async def initialize(self) -> types.InitializeResult: @@ -204,10 +216,16 @@ def get_server_capabilities(self) -> types.ServerCapabilities | None: return self._server_capabilities @property + @deprecated( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." + ) def experimental(self) -> ExperimentalClientFeatures: """Experimental APIs for tasks and other features. - WARNING: These APIs are experimental and may change without notice. + Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks + (SEP-1686) were removed from the MCP specification and are expected to + return as a separate MCP extension. Example: status = await session.experimental.get_task(task_id) diff --git a/src/mcp/client/websocket.py b/src/mcp/client/websocket.py index e8c8d9af87..a0e1b98801 100644 --- a/src/mcp/client/websocket.py +++ b/src/mcp/client/websocket.py @@ -6,6 +6,7 @@ import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import ValidationError +from typing_extensions import deprecated from websockets.asyncio.client import connect as ws_connect from websockets.typing import Subprotocol @@ -15,6 +16,10 @@ logger = logging.getLogger(__name__) +@deprecated( + "The WebSocket client transport is deprecated and will be removed in mcp 2.0. WebSocket was never part of" + " the MCP specification; use the streamable HTTP transport (`streamable_http_client`) instead." +) @asynccontextmanager async def websocket_client( url: str, @@ -25,6 +30,9 @@ async def websocket_client( """ WebSocket client transport for MCP, symmetrical to the server version. + Deprecated: this transport will be removed in mcp 2.0. WebSocket was never + part of the MCP specification; use the streamable HTTP transport instead. + Connects to 'url' using the 'mcp' subprotocol, then yields: (read_stream, write_stream) diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index 0d69836355..9caad02074 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -252,7 +252,8 @@ async def work(task: ServerTaskContext) -> CallToolResult: task_group = support.task_group if task_id is None: - session_scope = self._session.experimental.task_session_scope + features = self._session._experimental # pyright: ignore[reportPrivateUsage] + session_scope = features.task_session_scope if session_scope is not None: task_id = scoped_task_id(session_scope) diff --git a/src/mcp/server/experimental/task_context.py b/src/mcp/server/experimental/task_context.py index e6e14fc938..c9af82be41 100644 --- a/src/mcp/server/experimental/task_context.py +++ b/src/mcp/server/experimental/task_context.py @@ -488,12 +488,13 @@ async def elicit_as_task( create_result = CreateTaskResult.model_validate(response_data) client_task_id = create_result.task.taskId - # Poll the client's task using session.experimental - async for _ in self._session.experimental.poll_task(client_task_id): + # Poll the client's task using the session's experimental features + features = self._session._experimental # pyright: ignore[reportPrivateUsage] + async for _ in features.poll_task(client_task_id): pass # Get final result from client - result = await self._session.experimental.get_task_result( + result = await features.get_task_result( client_task_id, ElicitResult, ) @@ -594,12 +595,13 @@ async def create_message_as_task( create_result = CreateTaskResult.model_validate(response_data) client_task_id = create_result.task.taskId - # Poll the client's task using session.experimental - async for _ in self._session.experimental.poll_task(client_task_id): + # Poll the client's task using the session's experimental features + features = self._session._experimental # pyright: ignore[reportPrivateUsage] + async for _ in features.poll_task(client_task_id): pass # Get final result from client - result = await self._session.experimental.get_task_result( + result = await features.get_task_result( client_task_id, CreateMessageResult, ) diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py index 8e91faf73b..80f4e1cf86 100644 --- a/src/mcp/server/experimental/task_support.py +++ b/src/mcp/server/experimental/task_support.py @@ -106,8 +106,10 @@ def configure_session(self, session: ServerSession, *, stateless: bool = False) stateless: Whether the session belongs to a stateless server run """ session.add_response_router(self.handler) - if not stateless and session.experimental.task_session_scope is None: - session.experimental.task_session_scope = new_session_scope() + if not stateless: + features = session._experimental # pyright: ignore[reportPrivateUsage] + if features.task_session_scope is None: + features.task_session_scope = new_session_scope() @classmethod def in_memory(cls) -> "TaskSupport": diff --git a/src/mcp/server/lowlevel/experimental.py b/src/mcp/server/lowlevel/experimental.py index 737d6bb2cd..546757bf28 100644 --- a/src/mcp/server/lowlevel/experimental.py +++ b/src/mcp/server/lowlevel/experimental.py @@ -129,7 +129,8 @@ def enable_tasks( def _requestor_session_scope(self) -> str | None: """Return the task session scope of the session making the current request.""" - return self._server.request_context.session.experimental.task_session_scope + session = self._server.request_context.session + return session._experimental.task_session_scope # pyright: ignore[reportPrivateUsage] def _require_task_in_requestor_scope(self, task_id: str) -> None: """Reject task IDs that belong to a different session. diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 7d925de32b..25a8fde37c 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -80,7 +80,7 @@ async def main(): import jsonschema from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl -from typing_extensions import TypeVar +from typing_extensions import TypeVar, deprecated import mcp.types as types from mcp.server.experimental.request_context import Experimental @@ -244,10 +244,16 @@ def request_context( return request_ctx.get() @property + @deprecated( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." + ) def experimental(self) -> ExperimentalHandlers: """Experimental APIs for tasks and other features. - WARNING: These APIs are experimental and may change without notice. + Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks + (SEP-1686) were removed from the MCP specification and are expected to + return as a separate MCP extension. """ # We create this inline so we only add these capabilities _if_ they're actually used diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 5caf543187..f80971b012 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -44,6 +44,7 @@ async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl +from typing_extensions import deprecated import mcp.types as types from mcp.server.experimental.session_features import ExperimentalServerSessionFeatures @@ -108,14 +109,25 @@ def client_params(self) -> types.InitializeRequestParams | None: return self._client_params # pragma: no cover @property + def _experimental(self) -> ExperimentalServerSessionFeatures: + """Internal accessor for experimental features that skips the deprecation warning.""" + if self._experimental_features is None: + self._experimental_features = ExperimentalServerSessionFeatures(self) + return self._experimental_features + + @property + @deprecated( + "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" + " from the MCP specification and are expected to return as a separate MCP extension." + ) def experimental(self) -> ExperimentalServerSessionFeatures: """Experimental APIs for server→client task operations. - WARNING: These APIs are experimental and may change without notice. + Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks + (SEP-1686) were removed from the MCP specification and are expected to + return as a separate MCP extension. """ - if self._experimental_features is None: - self._experimental_features = ExperimentalServerSessionFeatures(self) - return self._experimental_features + return self._experimental def check_client_capability(self, capability: types.ClientCapabilities) -> bool: # pragma: no cover """Check if the client supports a specific capability.""" diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index 5d5efd16e9..2b21604a76 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -6,6 +6,7 @@ from pydantic_core import ValidationError from starlette.types import Receive, Scope, Send from starlette.websockets import WebSocket +from typing_extensions import deprecated import mcp.types as types from mcp.shared.message import SessionMessage @@ -13,11 +14,18 @@ logger = logging.getLogger(__name__) -@asynccontextmanager # pragma: no cover +@deprecated( # pragma: no cover + "The WebSocket server transport is deprecated and will be removed in mcp 2.0. WebSocket was never part of" + " the MCP specification; use the streamable HTTP transport instead." +) +@asynccontextmanager async def websocket_server(scope: Scope, receive: Receive, send: Send): """ WebSocket server transport for MCP. This is an ASGI application, suitable to be used with a framework like Starlette and a server like Hypercorn. + + Deprecated: this transport will be removed in mcp 2.0. WebSocket was never + part of the MCP specification; use the streamable HTTP transport instead. """ websocket = WebSocket(scope, receive, send) diff --git a/tests/experimental/tasks/conftest.py b/tests/experimental/tasks/conftest.py new file mode 100644 index 0000000000..77502190a2 --- /dev/null +++ b/tests/experimental/tasks/conftest.py @@ -0,0 +1,18 @@ +"""Shared configuration for the experimental tasks suite.""" + +from pathlib import Path + +import pytest + +_HERE = Path(__file__).parent + +# The tasks suite intentionally exercises the deprecated experimental tasks API. +_TASKS_DEPRECATION_IGNORE = pytest.mark.filterwarnings( + "ignore:The experimental tasks API is deprecated:DeprecationWarning" +) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if _HERE in item.path.parents: + item.add_marker(_TASKS_DEPRECATION_IGNORE) diff --git a/tests/experimental/tasks/test_deprecations.py b/tests/experimental/tasks/test_deprecations.py new file mode 100644 index 0000000000..1056e1fd94 --- /dev/null +++ b/tests/experimental/tasks/test_deprecations.py @@ -0,0 +1,72 @@ +"""Tests for the deprecation warnings on the experimental tasks entry points.""" + +import warnings + +import pytest + +import mcp.types as types +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.client.session import ClientSession +from mcp.server.lowlevel import Server +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.memory import create_client_server_memory_streams, create_connected_server_and_client_session + +_DEPRECATION_MATCH = "The experimental tasks API is deprecated" + + +@pytest.mark.anyio +async def test_client_session_experimental_property_is_deprecated() -> None: + async with create_client_server_memory_streams() as (client_streams, _): + read_stream, write_stream = client_streams + session = ClientSession(read_stream, write_stream) + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + features = session.experimental + # The cached path warns as well + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + assert session.experimental is features + + +@pytest.mark.anyio +async def test_client_session_experimental_task_handlers_kwarg_is_deprecated() -> None: + async with create_client_server_memory_streams() as (client_streams, _): + read_stream, write_stream = client_streams + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + ClientSession(read_stream, write_stream, experimental_task_handlers=ExperimentalTaskHandlers()) + + +@pytest.mark.anyio +async def test_server_session_experimental_property_is_deprecated() -> None: + init_options = InitializationOptions( + server_name="test-server", + server_version="0.1.0", + capabilities=types.ServerCapabilities(), + ) + async with create_client_server_memory_streams() as (_, server_streams): + read_stream, write_stream = server_streams + async with ServerSession(read_stream, write_stream, init_options) as session: + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + features = session.experimental + # The cached path warns as well. coverage.py misreports the branch arcs of the + # last statement in a nested `async with` body on Python 3.11+. + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): # pragma: no branch + assert session.experimental is features + + +def test_lowlevel_server_experimental_property_is_deprecated() -> None: + server: Server = Server("test-server") + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + handlers = server.experimental + # The cached path warns as well + with pytest.warns(DeprecationWarning, match=_DEPRECATION_MATCH): + assert server.experimental is handlers + + +@pytest.mark.anyio +async def test_plain_session_usage_does_not_warn() -> None: + """Clients and servers that don't touch the tasks API must not see deprecation warnings.""" + server: Server = Server("test-server") + with warnings.catch_warnings(): + warnings.simplefilter("error") + async with create_connected_server_and_client_session(server) as session: + await session.send_ping() diff --git a/tests/experimental/tasks/test_request_context.py b/tests/experimental/tasks/test_request_context.py index e52ec4403a..b204bec4f8 100644 --- a/tests/experimental/tasks/test_request_context.py +++ b/tests/experimental/tasks/test_request_context.py @@ -181,7 +181,7 @@ async def work(task: ServerTaskContext) -> Result: # deprecated argument has been reported. with pytest.raises(RuntimeError, match="Task support not enabled"): # The deliberate use of the deprecated overload is the point of this test. - await exp.run_task(work, task_id="explicitly-chosen") # pyright: ignore[reportDeprecated] + await exp.run_task(work, task_id="explicitly-chosen") @pytest.mark.anyio diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index f093cb4927..64790e2b08 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -10,6 +10,7 @@ from pydantic import AnyUrl from starlette.applications import Starlette from starlette.routing import WebSocketRoute +from starlette.types import Message from starlette.websockets import WebSocket from mcp.client.session import ClientSession @@ -30,6 +31,11 @@ SERVER_NAME = "test_server_for_WS" +# This suite intentionally exercises the deprecated WebSocket transport. +pytestmark = pytest.mark.filterwarnings( + "ignore:The WebSocket (client|server) transport is deprecated:DeprecationWarning" +) + @pytest.fixture def server_port() -> int: @@ -198,3 +204,22 @@ async def test_ws_client_timeout( assert len(result.contents) > 0 assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Read example" + + +def test_websocket_client_is_deprecated() -> None: + """Creating the websocket_client context manager emits a DeprecationWarning.""" + with pytest.warns(DeprecationWarning, match="The WebSocket client transport is deprecated"): + websocket_client("ws://127.0.0.1:1/ws") + + +def test_websocket_server_is_deprecated() -> None: + """Creating the websocket_server context manager emits a DeprecationWarning.""" + + async def receive() -> Message: + raise NotImplementedError + + async def send(message: Message) -> None: + raise NotImplementedError + + with pytest.warns(DeprecationWarning, match="The WebSocket server transport is deprecated"): + websocket_server({"type": "websocket"}, receive, send)