From 06aa84943c3d7bc4e602677ba83841978c30506c Mon Sep 17 00:00:00 2001 From: nate nowack Date: Thu, 23 Jul 2026 15:06:26 -0500 Subject: [PATCH] Expose era-neutral client server metadata (#4599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose era-neutral client metadata 🤖 Generated with Codex * Clarify pinned modern client metadata --- docs/clients/client.mdx | 18 ++++++---- fastmcp_slim/fastmcp/client/client.py | 36 ++++++++++++++++---- tests/client/client/test_mode_negotiation.py | 15 ++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 1024984c7..973043455 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -123,7 +123,7 @@ async with client: ## Connection Lifecycle -The client uses context managers for connection management. When you enter the context, the client establishes a connection and performs an MCP initialization handshake with the server. This handshake exchanges capabilities, server metadata, and instructions. +The client uses context managers for connection management. When you enter the context, the client establishes a connection and negotiates the protocol era with the server. Metadata returned by either legacy initialization or modern discovery is exposed through the same client properties. ```python from fastmcp import Client, FastMCP @@ -136,10 +136,12 @@ def greet(name: str) -> str: return f"Hello, {name}!" async with Client(mcp) as client: - # Initialization already happened automatically - print(f"Server: {client.initialize_result.server_info.name}") - print(f"Instructions: {client.initialize_result.instructions}") - print(f"Capabilities: {client.initialize_result.capabilities.tools}") + # Protocol negotiation already happened automatically + assert client.server_info is not None + assert client.server_capabilities is not None + print(f"Server: {client.server_info.name}") + print(f"Instructions: {client.instructions}") + print(f"Capabilities: {client.server_capabilities.tools}") ``` For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually: @@ -199,12 +201,16 @@ You can also pin a specific modern protocol version to adopt it directly, withou client = Client("https://example.com/mcp", mode="2026-07-28") ``` -Once connected, the negotiated version and the server's advertised capabilities are available as properties. Both are populated regardless of which era was negotiated, and both are `None` while the client is disconnected. +Once connected, the negotiated version, server identity, capabilities, and instructions are available as properties. They are populated from either the legacy `InitializeResult` or modern `DiscoverResult`, and reset to `None` when the client disconnects. `instructions` is also `None` when the server does not provide any. + +When you pin a modern version directly, the client skips discovery and adopts that version with minimal synthesized metadata. In that mode, `server_info` has an empty name and `instructions` is `None`. ```python async with Client("https://example.com/mcp", mode="auto") as client: print(client.protocol_version) # e.g. "2026-07-28" + print(client.server_info) # Implementation | None print(client.server_capabilities) # ServerCapabilities | None + print(client.instructions) # str | None ``` diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index c62429f29..50eb136b0 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -643,8 +643,9 @@ class Client( """Get the result of the initialization request. `None` on a modern (`server/discover`) connection, which negotiates via a - `DiscoverResult` rather than an `InitializeResult`. Use `protocol_version` / - `server_capabilities` for era-neutral access to the negotiated identity. + `DiscoverResult` rather than an `InitializeResult`. Use `protocol_version`, + `server_info`, `server_capabilities`, and `instructions` for era-neutral + access to the negotiated server metadata. """ return self._session_state.initialize_result @@ -668,6 +669,27 @@ class Client( session = self._session_state.session return session.server_capabilities if session is not None else None + @property + def server_info(self) -> mcp_types.Implementation | None: + """The session's server identity, or `None` when disconnected. + + Populated from whichever negotiation result the era produced (the + `InitializeResult` on legacy, the `DiscoverResult` on modern). A directly + pinned modern version uses a synthesized identity with an empty name. + """ + session = self._session_state.session + return session.server_info if session is not None else None + + @property + def instructions(self) -> str | None: + """The server's instructions, or `None` when absent or disconnected. + + Populated from whichever negotiation result the era produced (the + `InitializeResult` on legacy, the `DiscoverResult` on modern). + """ + session = self._session_state.session + return session.instructions if session is not None else None + def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) @@ -853,8 +875,9 @@ class Client( With `mode="auto"` or a pinned modern version, connect-time negotiation may adopt the modern `server/discover` era, which has no `InitializeResult`; in that case - this method raises. Read `protocol_version` / `server_capabilities` instead, or use - `mode="legacy"` when you need the handshake result. + this method raises. Read `protocol_version`, `server_info`, + `server_capabilities`, and `instructions` instead, or use `mode="legacy"` + when you need the handshake result. Args: timeout: Optional timeout for the initialization request (seconds or timedelta). @@ -887,8 +910,9 @@ class Client( if self.initialize_result is None: raise RuntimeError( "The client negotiated a modern protocol era (server/discover), which has " - "no InitializeResult. Read client.protocol_version / client.server_capabilities " - "instead, or construct the client with mode='legacy'." + "no InitializeResult. Inspect client.protocol_version, client.server_info, " + "client.server_capabilities, and client.instructions for the metadata " + "available in this mode, or construct the client with mode='legacy'." ) return self.initialize_result diff --git a/tests/client/client/test_mode_negotiation.py b/tests/client/client/test_mode_negotiation.py index 37eb96075..66904d2ee 100644 --- a/tests/client/client/test_mode_negotiation.py +++ b/tests/client/client/test_mode_negotiation.py @@ -248,6 +248,9 @@ class TestPinnedMode: async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client: assert client.protocol_version == LATEST_MODERN_VERSION assert client.initialize_result is None + assert client.server_info is not None + assert client.server_info.name == "" + assert client.instructions is None async def test_pinned_modern_call_tool(self, fastmcp_server): async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client: @@ -256,10 +259,20 @@ class TestPinnedMode: class TestConnectionProperties: + @pytest.mark.parametrize("mode", ["legacy", "auto"]) + async def test_server_metadata_available_across_eras(self, mode): + server = FastMCP("MetadataServer", instructions="Use the metadata tools.") + async with Client(server, mode=mode) as client: + assert client.server_info is not None + assert client.server_info.name == "MetadataServer" + assert client.instructions == "Use the metadata tools." + async def test_properties_none_before_connect(self, fastmcp_server): client = Client(fastmcp_server, mode="auto") assert client.protocol_version is None assert client.server_capabilities is None + assert client.server_info is None + assert client.instructions is None async def test_properties_none_after_disconnect(self, fastmcp_server): client = Client(fastmcp_server, mode="auto") @@ -267,6 +280,8 @@ class TestConnectionProperties: assert client.protocol_version is not None assert client.protocol_version is None assert client.server_capabilities is None + assert client.server_info is None + assert client.instructions is None class TestManualNegotiation: