Jeremiah Lowin commited on
Commit
0522f9f
·
unverified ·
2 Parent(s): 500d69ea2a14a4

Merge pull request #509 from jlowin/initialize-result

Browse files
src/fastmcp/client/client.py CHANGED
@@ -1,5 +1,5 @@
1
  import datetime
2
- from contextlib import AsyncExitStack
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
@@ -84,6 +84,7 @@ class Client:
84
  self._session: ClientSession | None = None
85
  self._exit_stack: AsyncExitStack | None = None
86
  self._nesting_counter: int = 0
 
87
 
88
  if log_handler is None:
89
  log_handler = default_log_handler
@@ -117,10 +118,19 @@ class Client:
117
  """Get the current active session. Raises RuntimeError if not connected."""
118
  if self._session is None:
119
  raise RuntimeError(
120
- "Client is not connected. Use 'async with client:' context manager first."
121
  )
122
  return self._session
123
 
 
 
 
 
 
 
 
 
 
124
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
125
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
126
  self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
@@ -135,27 +145,35 @@ class Client:
135
  """Check if the client is currently connected."""
136
  return self._session is not None
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  async def __aenter__(self):
139
  if self._nesting_counter == 0:
140
  # Create exit stack to manage both context managers
141
  stack = AsyncExitStack()
142
  await stack.__aenter__()
143
 
144
- # Add the exception handling context
145
- stack.enter_context(catch(get_catch_handlers()))
146
 
147
- # the above catch will only apply once this __aenter__ finishes so
148
- # we need to wrap the session creation in a new context in case it
149
- # raises errors itself
150
- with catch(get_catch_handlers()):
151
- # Create and enter the transport session using the exit stack
152
- session_cm = self.transport.connect_session(**self._session_kwargs)
153
- self._session = await stack.enter_async_context(session_cm)
154
-
155
- # Store the stack for cleanup in __aexit__
156
  self._exit_stack = stack
157
 
158
  self._nesting_counter += 1
 
159
  return self
160
 
161
  async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -168,7 +186,6 @@ class Client:
168
  await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
169
  finally:
170
  self._exit_stack = None
171
- self._session = None
172
 
173
  # --- MCP Client Methods ---
174
 
 
1
  import datetime
2
+ from contextlib import AsyncExitStack, asynccontextmanager
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
 
84
  self._session: ClientSession | None = None
85
  self._exit_stack: AsyncExitStack | None = None
86
  self._nesting_counter: int = 0
87
+ self._initialize_result: mcp.types.InitializeResult | None = None
88
 
89
  if log_handler is None:
90
  log_handler = default_log_handler
 
118
  """Get the current active session. Raises RuntimeError if not connected."""
119
  if self._session is None:
120
  raise RuntimeError(
121
+ "Client is not connected. Use the 'async with client:' context manager first."
122
  )
123
  return self._session
124
 
125
+ @property
126
+ def initialize_result(self) -> mcp.types.InitializeResult:
127
+ """Get the result of the initialization request."""
128
+ if self._initialize_result is None:
129
+ raise RuntimeError(
130
+ "Client is not connected. Use the 'async with client:' context manager first."
131
+ )
132
+ return self._initialize_result
133
+
134
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
135
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
136
  self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
 
145
  """Check if the client is currently connected."""
146
  return self._session is not None
147
 
148
+ @asynccontextmanager
149
+ async def _context_manager(self):
150
+ with catch(get_catch_handlers()):
151
+ async with self.transport.connect_session(
152
+ **self._session_kwargs
153
+ ) as session:
154
+ self._session = session
155
+ # Initialize the session
156
+ self._initialize_result = await self._session.initialize()
157
+
158
+ try:
159
+ yield
160
+ finally:
161
+ self._exit_stack = None
162
+ self._session = None
163
+ self._initialize_result = None
164
+
165
  async def __aenter__(self):
166
  if self._nesting_counter == 0:
167
  # Create exit stack to manage both context managers
168
  stack = AsyncExitStack()
169
  await stack.__aenter__()
170
 
171
+ await stack.enter_async_context(self._context_manager())
 
172
 
 
 
 
 
 
 
 
 
 
173
  self._exit_stack = stack
174
 
175
  self._nesting_counter += 1
176
+
177
  return self
178
 
179
  async def __aexit__(self, exc_type, exc_val, exc_tb):
 
186
  await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
187
  finally:
188
  self._exit_stack = None
 
189
 
190
  # --- MCP Client Methods ---
191
 
src/fastmcp/client/transports.py CHANGED
@@ -46,6 +46,7 @@ class ClientTransport(abc.ABC):
46
 
47
  A Transport is responsible for establishing and managing connections
48
  to an MCP server, and providing a ClientSession within an async context.
 
49
  """
50
 
51
  @abc.abstractmethod
@@ -54,7 +55,9 @@ class ClientTransport(abc.ABC):
54
  self, **session_kwargs: Unpack[SessionKwargs]
55
  ) -> AsyncIterator[ClientSession]:
56
  """
57
- Establishes a connection and yields an active, initialized ClientSession.
 
 
58
 
59
  The session is guaranteed to be valid only within the scope of the
60
  async context manager. Connection setup and teardown are handled
@@ -65,7 +68,7 @@ class ClientTransport(abc.ABC):
65
  constructor (e.g., callbacks, timeouts).
66
 
67
  Yields:
68
- An initialized mcp.ClientSession instance.
69
  """
70
  raise NotImplementedError
71
  yield None # type: ignore
@@ -94,7 +97,6 @@ class WSTransport(ClientTransport):
94
  async with ClientSession(
95
  read_stream, write_stream, **session_kwargs
96
  ) as session:
97
- await session.initialize() # Initialize after session creation
98
  yield session
99
 
100
  def __repr__(self) -> str:
@@ -143,7 +145,6 @@ class SSETransport(ClientTransport):
143
  async with ClientSession(
144
  read_stream, write_stream, **session_kwargs
145
  ) as session:
146
- await session.initialize()
147
  yield session
148
 
149
  def __repr__(self) -> str:
@@ -189,7 +190,6 @@ class StreamableHttpTransport(ClientTransport):
189
  async with ClientSession(
190
  read_stream, write_stream, **session_kwargs
191
  ) as session:
192
- await session.initialize()
193
  yield session
194
 
195
  def __repr__(self) -> str:
@@ -237,7 +237,6 @@ class StdioTransport(ClientTransport):
237
  async with ClientSession(
238
  read_stream, write_stream, **session_kwargs
239
  ) as session:
240
- await session.initialize()
241
  yield session
242
 
243
  def __repr__(self) -> str:
 
46
 
47
  A Transport is responsible for establishing and managing connections
48
  to an MCP server, and providing a ClientSession within an async context.
49
+
50
  """
51
 
52
  @abc.abstractmethod
 
55
  self, **session_kwargs: Unpack[SessionKwargs]
56
  ) -> AsyncIterator[ClientSession]:
57
  """
58
+ Establishes a connection and yields an active ClientSession.
59
+
60
+ The ClientSession is *not* expected to be initialized in this context manager.
61
 
62
  The session is guaranteed to be valid only within the scope of the
63
  async context manager. Connection setup and teardown are handled
 
68
  constructor (e.g., callbacks, timeouts).
69
 
70
  Yields:
71
+ A mcp.ClientSession instance.
72
  """
73
  raise NotImplementedError
74
  yield None # type: ignore
 
97
  async with ClientSession(
98
  read_stream, write_stream, **session_kwargs
99
  ) as session:
 
100
  yield session
101
 
102
  def __repr__(self) -> str:
 
145
  async with ClientSession(
146
  read_stream, write_stream, **session_kwargs
147
  ) as session:
 
148
  yield session
149
 
150
  def __repr__(self) -> str:
 
190
  async with ClientSession(
191
  read_stream, write_stream, **session_kwargs
192
  ) as session:
 
193
  yield session
194
 
195
  def __repr__(self) -> str:
 
237
  async with ClientSession(
238
  read_stream, write_stream, **session_kwargs
239
  ) as session:
 
240
  yield session
241
 
242
  def __repr__(self) -> str:
tests/client/test_client.py CHANGED
@@ -256,18 +256,51 @@ async def test_read_resource_mcp(fastmcp_server):
256
 
257
 
258
  async def test_client_connection(fastmcp_server):
259
- """Test that the client connects and disconnects properly."""
260
  client = Client(transport=FastMCPTransport(fastmcp_server))
261
 
262
- # Before connection
 
 
 
 
263
  assert not client.is_connected()
264
 
265
- # During connection
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  async with client:
267
  assert client.is_connected()
268
 
269
- # After connection
270
  assert not client.is_connected()
 
 
271
 
272
 
273
  async def test_client_nested_context_manager(fastmcp_server):
 
256
 
257
 
258
  async def test_client_connection(fastmcp_server):
259
+ """Test that connect is idempotent."""
260
  client = Client(transport=FastMCPTransport(fastmcp_server))
261
 
262
+ # Connect idempotently
263
+ async with client:
264
+ assert client.is_connected()
265
+ # Make a request to ensure connection is working
266
+ await client.ping()
267
  assert not client.is_connected()
268
 
269
+
270
+ async def test_initialize_result_connected(fastmcp_server):
271
+ """Test that initialize_result returns the correct result when connected."""
272
+ client = Client(transport=FastMCPTransport(fastmcp_server))
273
+
274
+ # Initialize result should not be accessible before connection
275
+ with pytest.raises(RuntimeError, match="Client is not connected"):
276
+ _ = client.initialize_result
277
+
278
+ async with client:
279
+ # Once connected, initialize_result should be available
280
+ result = client.initialize_result
281
+
282
+ # Verify the initialize result has expected properties
283
+ assert hasattr(result, "serverInfo")
284
+ assert result.serverInfo.name == "TestServer"
285
+ assert result.serverInfo.version is not None
286
+
287
+
288
+ async def test_initialize_result_disconnected(fastmcp_server):
289
+ """Test that initialize_result raises an error when not connected."""
290
+ client = Client(transport=FastMCPTransport(fastmcp_server))
291
+
292
+ # Initialize result should not be accessible before connection
293
+ with pytest.raises(RuntimeError, match="Client is not connected"):
294
+ _ = client.initialize_result
295
+
296
+ # Connect and then disconnect
297
  async with client:
298
  assert client.is_connected()
299
 
300
+ # After disconnection, initialize_result should raise an error
301
  assert not client.is_connected()
302
+ with pytest.raises(RuntimeError, match="Client is not connected"):
303
+ _ = client.initialize_result
304
 
305
 
306
  async def test_client_nested_context_manager(fastmcp_server):
tests/server/test_server_interactions.py CHANGED
@@ -640,7 +640,6 @@ class TestToolContextInjection:
640
  assert len(result) == 1
641
  content = result[0]
642
  assert isinstance(content, TextContent)
643
- assert content.text == "1"
644
 
645
  async def test_async_context(self):
646
  """Test that context works in async functions."""
@@ -656,8 +655,7 @@ class TestToolContextInjection:
656
  assert len(result) == 1
657
  content = result[0]
658
  assert isinstance(content, TextContent)
659
- assert "Async request" in content.text
660
- assert "42" in content.text
661
 
662
  async def test_optional_context(self):
663
  """Test that context is optional."""
@@ -798,7 +796,7 @@ class TestResourceContext:
798
  async with Client(mcp) as client:
799
  result = await client.read_resource(AnyUrl("resource://test"))
800
  assert isinstance(result[0], TextResourceContents)
801
- assert result[0].text == "1"
802
 
803
 
804
  class TestResourceTemplates:
@@ -1096,7 +1094,7 @@ class TestResourceTemplateContext:
1096
  async with Client(mcp) as client:
1097
  result = await client.read_resource(AnyUrl("resource://test"))
1098
  assert isinstance(result[0], TextResourceContents)
1099
- assert result[0].text == "Resource template: test 1"
1100
 
1101
 
1102
  class TestPrompts:
 
640
  assert len(result) == 1
641
  content = result[0]
642
  assert isinstance(content, TextContent)
 
643
 
644
  async def test_async_context(self):
645
  """Test that context works in async functions."""
 
655
  assert len(result) == 1
656
  content = result[0]
657
  assert isinstance(content, TextContent)
658
+ assert content.text == "Async request 2: 42"
 
659
 
660
  async def test_optional_context(self):
661
  """Test that context is optional."""
 
796
  async with Client(mcp) as client:
797
  result = await client.read_resource(AnyUrl("resource://test"))
798
  assert isinstance(result[0], TextResourceContents)
799
+ assert result[0].text == "2"
800
 
801
 
802
  class TestResourceTemplates:
 
1094
  async with Client(mcp) as client:
1095
  result = await client.read_resource(AnyUrl("resource://test"))
1096
  assert isinstance(result[0], TextResourceContents)
1097
+ assert result[0].text.startswith("Resource template: test 2")
1098
 
1099
 
1100
  class TestPrompts: