diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 0cea454a77..31ab7a28f6 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -220,6 +220,24 @@ async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.Emp """Send a ping request.""" return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) + async def server_discover(self, *, meta: RequestParamsMeta | None = None) -> types.DiscoverResult: + """Send a ``server/discover`` request. + + Returns the server's supported protocol versions, current capabilities, and + instructions. Server identity is available in the result's ``_meta`` field + under the key :data:`~mcp.types.SERVER_INFO_META_KEY` + (``"io.modelcontextprotocol/serverInfo"``). + + Example:: + + result = await session.server_discover() + server_info_raw = (result.meta or {}).get("io.modelcontextprotocol/serverInfo") + """ + return await self.send_request( + types.ServerDiscoverRequest(params=types.RequestParams(_meta=meta)), + types.DiscoverResult, + ) + async def send_progress_notification( self, progress_token: str | int, diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 59de0ace45..57fba631ee 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -202,6 +202,8 @@ def __init__( logger.debug("Initializing server %r", name) # Populate internal handler dicts from on_* kwargs + self._request_handlers["server/discover"] = self._handle_discover + self._request_handlers.update( { method: handler @@ -342,6 +344,33 @@ def experimental(self) -> ExperimentalHandlers[LifespanResultT]: ) return self._experimental_handlers + async def _handle_discover( + self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None + ) -> types.DiscoverResult: + """Default ``server/discover`` handler. + + Returns the set of supported protocol versions, the server's current + capabilities, and any instructions. Server identity is stamped into + the result's ``_meta`` field under :data:`~mcp.types.SERVER_INFO_META_KEY` + per the MCP specification (spec #3002). + """ + from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS + + server_info = types.Implementation( + name=self.name, + version=self.version or "", + title=self.title, + description=self.description, + website_url=self.website_url, + icons=self.icons, + ) + return types.DiscoverResult( + supported_versions=list(SUPPORTED_PROTOCOL_VERSIONS), + capabilities=self.get_capabilities(NotificationOptions(), {}), + instructions=self.instructions, + _meta={types.SERVER_INFO_META_KEY: server_info.model_dump(mode="json", by_alias=True, exclude_none=True)}, + ) + @property def session_manager(self) -> StreamableHTTPSessionManager: """Get the StreamableHTTP session manager. diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index b442303937..29bc573683 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -8,6 +8,7 @@ from mcp.types._types import ( DEFAULT_NEGOTIATED_VERSION, LATEST_PROTOCOL_VERSION, + SERVER_INFO_META_KEY, TASK_FORBIDDEN, TASK_OPTIONAL, TASK_REQUIRED, @@ -47,6 +48,7 @@ CreateMessageResult, CreateMessageResultWithTools, CreateTaskResult, + DiscoverResult, ElicitationCapability, ElicitationRequiredErrorData, ElicitCompleteNotification, @@ -139,6 +141,7 @@ SamplingMessageContentBlock, SamplingToolsCapability, ServerCapabilities, + ServerDiscoverRequest, ServerNotification, ServerRequest, ServerResult, @@ -208,6 +211,7 @@ # Protocol version constants "LATEST_PROTOCOL_VERSION", "DEFAULT_NEGOTIATED_VERSION", + "SERVER_INFO_META_KEY", # Task execution mode constants "TASK_FORBIDDEN", "TASK_OPTIONAL", @@ -336,6 +340,7 @@ "PingRequest", "ReadResourceRequest", "ReadResourceRequestParams", + "ServerDiscoverRequest", "SetLevelRequest", "SetLevelRequestParams", "SubscribeRequest", @@ -349,6 +354,7 @@ "CreateMessageResult", "CreateMessageResultWithTools", "CreateTaskResult", + "DiscoverResult", "ElicitResult", "ElicitationRequiredErrorData", "GetPromptResult", diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index 9005d253af..3588790223 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -15,6 +15,14 @@ You can find the latest specification at https://modelcontextprotocol.io/specification/latest. """ +SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo" +"""The _meta key under which servers stamp their identity in result objects. + +Per the MCP specification (#3002), servers SHOULD include this key in the ``_meta`` +field of every result instead of exposing ``serverInfo`` as a top-level field on +``DiscoverResult``. +""" + DEFAULT_NEGOTIATED_VERSION = "2025-03-26" """The default negotiated version of the Model Context Protocol when no version is specified. @@ -1546,6 +1554,53 @@ class RootsListChangedNotification( params: NotificationParams | None = None +class ServerDiscoverRequest(Request[RequestParams | None, Literal["server/discover"]]): + """Sent from the client to the server to discover its capabilities and identity. + + Clients MAY call ``server/discover`` to learn which protocol versions the server + supports, the server's current capabilities, and any instructions, before or after + the MCP handshake. The server's identity is returned in the ``_meta`` field of + :class:`DiscoverResult` under the key :data:`SERVER_INFO_META_KEY`. + """ + + method: Literal["server/discover"] = "server/discover" + params: RequestParams | None = None + + +class DiscoverResult(Result): + """The result of a ``server/discover`` request. + + Per the MCP specification (#3002), server identity is carried in the ``_meta`` + field under the key :data:`SERVER_INFO_META_KEY` rather than as a top-level + ``serverInfo`` field. + + Example wire shape:: + + { + "supportedVersions": ["2025-11-25"], + "capabilities": {...}, + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "my-server", + "version": "1.0.0" + } + } + } + """ + + supported_versions: list[str] + """MCP protocol versions supported by this server. + + Clients should choose a version from this list for subsequent requests. + """ + + capabilities: ServerCapabilities + """The server's current capabilities.""" + + instructions: str | None = None + """Natural-language guidance describing the server and its features.""" + + class CancelledNotificationParams(NotificationParams): """Parameters for cancellation notifications.""" @@ -1596,6 +1651,7 @@ class ElicitCompleteNotification( ClientRequest = ( PingRequest | InitializeRequest + | ServerDiscoverRequest | CompleteRequest | SetLevelRequest | GetPromptRequest @@ -1761,6 +1817,7 @@ class ElicitationRequiredErrorData(MCPModel): ServerResult = ( EmptyResult | InitializeResult + | DiscoverResult | CompleteResult | GetPromptResult | ListPromptsResult diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py new file mode 100644 index 0000000000..f5ce5cc544 --- /dev/null +++ b/tests/server/lowlevel/test_server_discover.py @@ -0,0 +1,145 @@ +"""Tests for the `server/discover` handler and DiscoverResult type. + +Verifies that: +- The handler is registered on every Server instance by default. +- The response contains supportedVersions and capabilities. +- serverInfo is stamped into the result _meta field, NOT exposed as a + top-level field, in accordance with spec #3002. +""" + +import pytest + +from mcp import Client +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + Icon, + Implementation, + PromptsCapability, + RequestParams, + SERVER_INFO_META_KEY, + ToolsCapability, + ListPromptsResult, + ListToolsResult, + PaginatedRequestParams, + Prompt, + Tool, +) + + +@pytest.mark.anyio +async def test_server_discover_returns_supported_versions() -> None: + """server/discover result contains a non-empty list of supported protocol versions.""" + server = Server("test-server") + async with Client(server) as client: + result = await client.session.server_discover() + assert isinstance(result.supported_versions, list) + assert len(result.supported_versions) > 0 + # The current protocol version must be included + assert any("2025" in v or "2026" in v for v in result.supported_versions) + + +@pytest.mark.anyio +async def test_server_discover_server_info_in_meta_not_top_level() -> None: + """serverInfo MUST be in _meta, not a top-level field of DiscoverResult (spec #3002).""" + server = Server("my-server", version="1.2.3") + async with Client(server) as client: + result = await client.session.server_discover() + + # serverInfo must not be a direct attribute of DiscoverResult + assert not hasattr(result, "server_info"), ( + "DiscoverResult MUST NOT have a top-level 'server_info' field; " + "server identity belongs in _meta per spec #3002" + ) + + # serverInfo MUST be in _meta + assert result.meta is not None, "result._meta must be set" + assert SERVER_INFO_META_KEY in result.meta, ( + f"result._meta must contain '{SERVER_INFO_META_KEY}'" + ) + + +@pytest.mark.anyio +async def test_server_discover_server_info_fields() -> None: + """serverInfo stamp in _meta reflects the Server constructor arguments.""" + icons = [Icon(src="https://example.test/icon.png")] + server = Server( + "info-server", + version="9.9.9", + title="Info Server", + description="A server for testing discover.", + website_url="https://example.test", + icons=icons, + ) + async with Client(server) as client: + result = await client.session.server_discover() + + assert result.meta is not None + raw_stamp = result.meta[SERVER_INFO_META_KEY] + stamped = Implementation.model_validate(raw_stamp) + assert stamped == Implementation( + name="info-server", + version="9.9.9", + title="Info Server", + description="A server for testing discover.", + website_url="https://example.test", + icons=icons, + ) + + +@pytest.mark.anyio +async def test_server_discover_unversioned_server_reports_empty_version() -> None: + """An unversioned server reports version='' rather than the SDK's own version.""" + server = Server("unversioned") + async with Client(server) as client: + result = await client.session.server_discover() + + assert result.meta is not None + stamp = result.meta[SERVER_INFO_META_KEY] + assert stamp["name"] == "unversioned" + assert stamp["version"] == "" + + +@pytest.mark.anyio +async def test_server_discover_capabilities_reflect_registered_handlers() -> None: + """Capabilities in DiscoverResult match what the server has registered.""" + + async def handle_list_prompts( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult(prompts=[Prompt(name="p")]) + + async def handle_list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="t", inputSchema={})]) + + server = Server( + "caps-server", + on_list_prompts=handle_list_prompts, + on_list_tools=handle_list_tools, + ) + async with Client(server) as client: + result = await client.session.server_discover() + + assert result.capabilities.prompts is not None + assert result.capabilities.tools is not None + # Resources were not registered + assert result.capabilities.resources is None + + +@pytest.mark.anyio +async def test_server_discover_instructions_threaded_through() -> None: + """instructions in DiscoverResult match what the Server was constructed with.""" + server = Server("inst-server", instructions="Read the docs first.") + async with Client(server) as client: + result = await client.session.server_discover() + assert result.instructions == "Read the docs first." + + +@pytest.mark.anyio +async def test_server_discover_no_instructions_by_default() -> None: + """instructions defaults to None when not set.""" + server = Server("bare") + async with Client(server) as client: + result = await client.session.server_discover() + assert result.instructions is None