Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,6 +48,7 @@
CreateMessageResult,
CreateMessageResultWithTools,
CreateTaskResult,
DiscoverResult,
ElicitationCapability,
ElicitationRequiredErrorData,
ElicitCompleteNotification,
Expand Down Expand Up @@ -139,6 +141,7 @@
SamplingMessageContentBlock,
SamplingToolsCapability,
ServerCapabilities,
ServerDiscoverRequest,
ServerNotification,
ServerRequest,
ServerResult,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -336,6 +340,7 @@
"PingRequest",
"ReadResourceRequest",
"ReadResourceRequestParams",
"ServerDiscoverRequest",
"SetLevelRequest",
"SetLevelRequestParams",
"SubscribeRequest",
Expand All @@ -349,6 +354,7 @@
"CreateMessageResult",
"CreateMessageResultWithTools",
"CreateTaskResult",
"DiscoverResult",
"ElicitResult",
"ElicitationRequiredErrorData",
"GetPromptResult",
Expand Down
57 changes: 57 additions & 0 deletions src/mcp/types/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -1596,6 +1651,7 @@ class ElicitCompleteNotification(
ClientRequest = (
PingRequest
| InitializeRequest
| ServerDiscoverRequest
| CompleteRequest
| SetLevelRequest
| GetPromptRequest
Expand Down Expand Up @@ -1761,6 +1817,7 @@ class ElicitationRequiredErrorData(MCPModel):
ServerResult = (
EmptyResult
| InitializeResult
| DiscoverResult
| CompleteResult
| GetPromptResult
| ListPromptsResult
Expand Down
145 changes: 145 additions & 0 deletions tests/server/lowlevel/test_server_discover.py
Original file line number Diff line number Diff line change
@@ -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