Skip to content

Commit fce8b9e

Browse files
Simplify API: Client(server) instead of Client.from_server(server)
- Refactor Client constructor to accept Server/FastMCP directly - Remove from_server() classmethod (simpler, matches FastMCP pattern) - Update all tests and examples to use new pattern Github-Issue:#1728
1 parent e231f3e commit fce8b9e

19 files changed

+163
-224
lines changed

examples/fastmcp/weather_structured.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ async def test() -> None:
157157
print("Testing Weather Service Tools (via MCP protocol)\n")
158158
print("=" * 80)
159159

160-
async with Client.from_server(mcp) as client:
160+
async with Client(mcp) as client:
161161
# Test get_weather
162162
result = await client.call_tool("get_weather", {"city": "London"})
163163
print("\nWeather in London:")

src/mcp/client/client.py

Lines changed: 47 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
from __future__ import annotations
44

55
import logging
6-
from collections.abc import AsyncGenerator
7-
from contextlib import AsyncExitStack, asynccontextmanager
6+
from contextlib import AsyncExitStack
87
from typing import Any
98

10-
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
119
from pydantic import AnyUrl
1210

1311
import mcp.types as types
@@ -22,37 +20,35 @@
2220
from mcp.client.transports.memory import InMemoryTransport
2321
from mcp.server import Server
2422
from mcp.server.fastmcp import FastMCP
25-
from mcp.shared.message import SessionMessage
2623
from mcp.shared.session import ProgressFnT
2724

2825
logger = logging.getLogger(__name__)
2926

3027

3128
class Client:
3229
"""
33-
A high-level MCP client that manages transport and session lifecycle.
30+
A high-level MCP client for connecting to MCP servers.
3431
35-
The Client class provides a unified interface for connecting to MCP servers
36-
using different transports (in-memory, stdio, HTTP, etc.) and exposes
37-
all ClientSession functionality with simpler lifecycle management.
32+
The Client class provides a simple interface for testing MCP servers
33+
using in-memory transport. Pass a Server or FastMCP instance directly
34+
to the constructor.
3835
39-
Example with in-memory transport (for testing):
36+
Example:
4037
server = FastMCP("test")
4138
42-
async with Client.from_server(server) as client:
43-
tools = await client.list_tools()
44-
result = await client.call_tool("my_tool", {"arg": "value"})
39+
@server.tool()
40+
def add(a: int, b: int) -> int:
41+
return a + b
4542
46-
Example with custom streams:
47-
async with Client(read_stream, write_stream) as client:
48-
await client.initialize()
49-
# Use client...
43+
async with Client(server) as client:
44+
result = await client.call_tool("add", {"a": 1, "b": 2})
5045
"""
5146

5247
def __init__(
5348
self,
54-
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
55-
write_stream: MemoryObjectSendStream[SessionMessage],
49+
server: Server[Any] | FastMCP,
50+
*,
51+
raise_exceptions: bool = False,
5652
read_timeout_seconds: float | None = None,
5753
sampling_callback: SamplingFnT | None = None,
5854
list_roots_callback: ListRootsFnT | None = None,
@@ -62,11 +58,11 @@ def __init__(
6258
elicitation_callback: ElicitationFnT | None = None,
6359
) -> None:
6460
"""
65-
Initialize the client with transport streams.
61+
Initialize the client with a server.
6662
6763
Args:
68-
read_stream: Stream for receiving messages from the server
69-
write_stream: Stream for sending messages to the server
64+
server: The MCP server to connect to (Server or FastMCP instance)
65+
raise_exceptions: Whether to raise exceptions from the server
7066
read_timeout_seconds: Timeout for read operations
7167
sampling_callback: Callback for handling sampling requests
7268
list_roots_callback: Callback for handling list roots requests
@@ -75,8 +71,8 @@ def __init__(
7571
client_info: Client implementation info to send to server
7672
elicitation_callback: Callback for handling elicitation requests
7773
"""
78-
self._read_stream = read_stream
79-
self._write_stream = write_stream
74+
self._server = server
75+
self._raise_exceptions = raise_exceptions
8076
self._read_timeout_seconds = read_timeout_seconds
8177
self._sampling_callback = sampling_callback
8278
self._list_roots_callback = list_roots_callback
@@ -88,86 +84,40 @@ def __init__(
8884
self._session: ClientSession | None = None
8985
self._exit_stack: AsyncExitStack | None = None
9086

91-
@classmethod
92-
@asynccontextmanager
93-
async def from_server(
94-
cls,
95-
server: Server[Any] | FastMCP,
96-
read_timeout_seconds: float | None = None,
97-
sampling_callback: SamplingFnT | None = None,
98-
list_roots_callback: ListRootsFnT | None = None,
99-
logging_callback: LoggingFnT | None = None,
100-
message_handler: MessageHandlerFnT | None = None,
101-
client_info: types.Implementation | None = None,
102-
raise_exceptions: bool = False,
103-
elicitation_callback: ElicitationFnT | None = None,
104-
) -> AsyncGenerator[Client, None]:
105-
"""
106-
Create a client connected to an in-memory server.
107-
108-
This is a convenience method that creates an in-memory transport,
109-
starts the server, and returns an initialized client.
110-
111-
Args:
112-
server: The MCP server to connect to (Server or FastMCP instance)
113-
read_timeout_seconds: Timeout for read operations
114-
sampling_callback: Callback for handling sampling requests
115-
list_roots_callback: Callback for handling list roots requests
116-
logging_callback: Callback for handling logging notifications
117-
message_handler: Callback for handling raw messages
118-
client_info: Client implementation info to send to server
119-
raise_exceptions: Whether to raise exceptions from the server
120-
elicitation_callback: Callback for handling elicitation requests
121-
122-
Yields:
123-
An initialized Client connected to the server
124-
125-
Example:
126-
server = FastMCP("test")
127-
128-
@server.tool()
129-
def my_tool(arg: str) -> str:
130-
return f"Result: {arg}"
131-
132-
async with Client.from_server(server) as client:
133-
result = await client.call_tool("my_tool", {"arg": "value"})
134-
"""
135-
transport = InMemoryTransport(server, raise_exceptions=raise_exceptions)
136-
async with transport.connect() as (read_stream, write_stream):
137-
client = cls(
138-
read_stream=read_stream,
139-
write_stream=write_stream,
140-
read_timeout_seconds=read_timeout_seconds,
141-
sampling_callback=sampling_callback,
142-
list_roots_callback=list_roots_callback,
143-
logging_callback=logging_callback,
144-
message_handler=message_handler,
145-
client_info=client_info,
146-
elicitation_callback=elicitation_callback,
147-
)
148-
async with client:
149-
await client.initialize()
150-
yield client
151-
15287
async def __aenter__(self) -> Client:
15388
"""Enter the async context manager."""
15489
self._exit_stack = AsyncExitStack()
15590
await self._exit_stack.__aenter__()
15691

157-
self._session = await self._exit_stack.enter_async_context(
158-
ClientSession(
159-
read_stream=self._read_stream,
160-
write_stream=self._write_stream,
161-
read_timeout_seconds=self._read_timeout_seconds,
162-
sampling_callback=self._sampling_callback,
163-
list_roots_callback=self._list_roots_callback,
164-
logging_callback=self._logging_callback,
165-
message_handler=self._message_handler,
166-
client_info=self._client_info,
167-
elicitation_callback=self._elicitation_callback,
92+
try:
93+
# Create transport and connect
94+
transport = InMemoryTransport(self._server, raise_exceptions=self._raise_exceptions)
95+
read_stream, write_stream = await self._exit_stack.enter_async_context(
96+
transport.connect()
97+
)
98+
99+
# Create session
100+
self._session = await self._exit_stack.enter_async_context(
101+
ClientSession(
102+
read_stream=read_stream,
103+
write_stream=write_stream,
104+
read_timeout_seconds=self._read_timeout_seconds,
105+
sampling_callback=self._sampling_callback,
106+
list_roots_callback=self._list_roots_callback,
107+
logging_callback=self._logging_callback,
108+
message_handler=self._message_handler,
109+
client_info=self._client_info,
110+
elicitation_callback=self._elicitation_callback,
111+
)
168112
)
169-
)
170-
return self
113+
114+
# Initialize the session
115+
await self._session.initialize()
116+
117+
return self
118+
except Exception:
119+
await self._exit_stack.__aexit__(None, None, None)
120+
raise
171121

172122
async def __aexit__(
173123
self,
@@ -194,17 +144,6 @@ def session(self) -> ClientSession:
194144
raise RuntimeError("Client must be used within an async context manager")
195145
return self._session
196146

197-
async def initialize(self) -> types.InitializeResult:
198-
"""
199-
Initialize the MCP session with the server.
200-
201-
This must be called before using other client methods.
202-
203-
Returns:
204-
The initialization result from the server
205-
"""
206-
return await self.session.initialize()
207-
208147
def get_server_capabilities(self) -> types.ServerCapabilities | None:
209148
"""
210149
Return the server capabilities received during initialization.

tests/client/test_client.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,26 @@ def greeting_prompt(name: str) -> str:
5656

5757

5858
class TestClientFromServer:
59-
"""Tests for Client.from_server() class method."""
59+
"""Tests for Client() class method."""
6060

6161
@pytest.mark.anyio
6262
async def test_creates_client(self, fastmcp_server: FastMCP):
6363
"""Test that from_server creates a connected client."""
64-
async with Client.from_server(fastmcp_server) as client:
64+
async with Client(fastmcp_server) as client:
6565
assert client is not None
6666

6767
@pytest.mark.anyio
6868
async def test_client_is_initialized(self, fastmcp_server: FastMCP):
6969
"""Test that the client is initialized after entering context."""
70-
async with Client.from_server(fastmcp_server) as client:
70+
async with Client(fastmcp_server) as client:
7171
caps = client.get_server_capabilities()
7272
assert caps is not None
7373
assert caps.tools is not None
7474

7575
@pytest.mark.anyio
7676
async def test_with_simple_server(self, simple_server: Server):
7777
"""Test that from_server works with a basic Server instance."""
78-
async with Client.from_server(simple_server) as client:
78+
async with Client(simple_server) as client:
7979
assert client is not None
8080
caps = client.get_server_capabilities()
8181
assert caps is not None
@@ -87,7 +87,7 @@ class TestClientPing:
8787
@pytest.mark.anyio
8888
async def test_ping_returns_empty_result(self, fastmcp_server: FastMCP):
8989
"""Test that ping returns an EmptyResult."""
90-
async with Client.from_server(fastmcp_server) as client:
90+
async with Client(fastmcp_server) as client:
9191
result = await client.send_ping()
9292
assert isinstance(result, EmptyResult)
9393

@@ -98,7 +98,7 @@ class TestClientTools:
9898
@pytest.mark.anyio
9999
async def test_list_tools(self, fastmcp_server: FastMCP):
100100
"""Test listing tools."""
101-
async with Client.from_server(fastmcp_server) as client:
101+
async with Client(fastmcp_server) as client:
102102
result = await client.list_tools()
103103
assert result.tools is not None
104104
tool_names = [t.name for t in result.tools]
@@ -110,14 +110,14 @@ async def test_list_tools_with_pagination(self, fastmcp_server: FastMCP):
110110
"""Test listing tools with pagination params."""
111111
from mcp.types import PaginatedRequestParams
112112

113-
async with Client.from_server(fastmcp_server) as client:
113+
async with Client(fastmcp_server) as client:
114114
result = await client.list_tools(params=PaginatedRequestParams())
115115
assert result.tools is not None
116116

117117
@pytest.mark.anyio
118118
async def test_call_tool(self, fastmcp_server: FastMCP):
119119
"""Test calling a tool."""
120-
async with Client.from_server(fastmcp_server) as client:
120+
async with Client(fastmcp_server) as client:
121121
result = await client.call_tool("greet", {"name": "World"})
122122
assert result.content is not None
123123
assert len(result.content) > 0
@@ -127,7 +127,7 @@ async def test_call_tool(self, fastmcp_server: FastMCP):
127127
@pytest.mark.anyio
128128
async def test_call_tool_with_multiple_args(self, fastmcp_server: FastMCP):
129129
"""Test calling a tool with multiple arguments."""
130-
async with Client.from_server(fastmcp_server) as client:
130+
async with Client(fastmcp_server) as client:
131131
result = await client.call_tool("add", {"a": 5, "b": 3})
132132
assert result.content is not None
133133
content_str = str(result.content[0])
@@ -140,15 +140,15 @@ class TestClientResources:
140140
@pytest.mark.anyio
141141
async def test_list_resources(self, fastmcp_server: FastMCP):
142142
"""Test listing resources."""
143-
async with Client.from_server(fastmcp_server) as client:
143+
async with Client(fastmcp_server) as client:
144144
result = await client.list_resources()
145145
# FastMCP may have different resource listing behavior
146146
assert result is not None
147147

148148
@pytest.mark.anyio
149149
async def test_read_resource(self, fastmcp_server: FastMCP):
150150
"""Test reading a resource."""
151-
async with Client.from_server(fastmcp_server) as client:
151+
async with Client(fastmcp_server) as client:
152152
result = await client.read_resource(AnyUrl("test://resource"))
153153
assert result.contents is not None
154154
assert len(result.contents) > 0
@@ -160,15 +160,15 @@ class TestClientPrompts:
160160
@pytest.mark.anyio
161161
async def test_list_prompts(self, fastmcp_server: FastMCP):
162162
"""Test listing prompts."""
163-
async with Client.from_server(fastmcp_server) as client:
163+
async with Client(fastmcp_server) as client:
164164
result = await client.list_prompts()
165165
prompt_names = [p.name for p in result.prompts]
166166
assert "greeting_prompt" in prompt_names
167167

168168
@pytest.mark.anyio
169169
async def test_get_prompt(self, fastmcp_server: FastMCP):
170170
"""Test getting a prompt."""
171-
async with Client.from_server(fastmcp_server) as client:
171+
async with Client(fastmcp_server) as client:
172172
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
173173
assert result.messages is not None
174174
assert len(result.messages) > 0
@@ -182,14 +182,14 @@ async def test_session_property(self, fastmcp_server: FastMCP):
182182
"""Test that the session property returns the ClientSession."""
183183
from mcp.client.session import ClientSession
184184

185-
async with Client.from_server(fastmcp_server) as client:
185+
async with Client(fastmcp_server) as client:
186186
session = client.session
187187
assert isinstance(session, ClientSession)
188188

189189
@pytest.mark.anyio
190190
async def test_session_is_same_as_internal(self, fastmcp_server: FastMCP):
191191
"""Test that session property returns consistent instance."""
192-
async with Client.from_server(fastmcp_server) as client:
192+
async with Client(fastmcp_server) as client:
193193
session1 = client.session
194194
session2 = client.session
195195
assert session1 is session2
@@ -206,7 +206,7 @@ async def test_logging_callback(self, fastmcp_server: FastMCP):
206206
async def logging_callback(params):
207207
log_messages.append(params)
208208

209-
async with Client.from_server(
209+
async with Client(
210210
fastmcp_server,
211211
logging_callback=logging_callback,
212212
) as client:
@@ -221,7 +221,7 @@ class TestClientContextManager:
221221
@pytest.mark.anyio
222222
async def test_enters_and_exits_cleanly(self, fastmcp_server: FastMCP):
223223
"""Test that the client enters and exits cleanly."""
224-
async with Client.from_server(fastmcp_server) as client:
224+
async with Client(fastmcp_server) as client:
225225
# Should be able to use client
226226
await client.send_ping()
227227
# After exiting, resources should be cleaned up
@@ -230,7 +230,7 @@ async def test_enters_and_exits_cleanly(self, fastmcp_server: FastMCP):
230230
async def test_exception_during_use(self, fastmcp_server: FastMCP):
231231
"""Test that exceptions during use don't prevent cleanup."""
232232
with pytest.raises(Exception): # May be wrapped in ExceptionGroup by anyio
233-
async with Client.from_server(fastmcp_server) as client:
233+
async with Client(fastmcp_server) as client:
234234
await client.send_ping()
235235
raise ValueError("Test exception")
236236
# Should exit cleanly despite exception
@@ -242,7 +242,7 @@ class TestClientServerCapabilities:
242242
@pytest.mark.anyio
243243
async def test_get_server_capabilities_after_init(self, fastmcp_server: FastMCP):
244244
"""Test getting server capabilities after initialization."""
245-
async with Client.from_server(fastmcp_server) as client:
245+
async with Client(fastmcp_server) as client:
246246
caps = client.get_server_capabilities()
247247
assert caps is not None
248248
# FastMCP should advertise tools capability

0 commit comments

Comments
 (0)