Expose era-neutral client server metadata (#4599)

* Expose era-neutral client metadata

🤖 Generated with Codex

* Clarify pinned modern client metadata
This commit is contained in:
nate nowack 2026-07-23 15:06:26 -05:00 committed by GitHub
commit 06aa84943c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 57 additions and 12 deletions

View file

@ -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
```
<Note>

View file

@ -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

View file

@ -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: