Jeremiah Lowin commited on
Commit
0c29eb8
·
1 Parent(s): 7fb0010

Add keep_alive param to reuse subprocess

Browse files
docs/clients/client.mdx CHANGED
@@ -18,18 +18,6 @@ The FastMCP Client architecture separates the protocol logic (`Client`) from the
18
  - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks.
19
  - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory).
20
 
21
- ```python
22
- from fastmcp import Client, FastMCP
23
- from fastmcp.client import (
24
- RootsHandler,
25
- RootsList,
26
- LogHandler,
27
- MessageHandler,
28
- SamplingHandler,
29
- ProgressHandler # For handling progress notifications
30
- )
31
- ```
32
-
33
  ### Transports
34
 
35
  Clients must be initialized with a `transport`. You can either provide an already instantiated transport object, or provide a transport source and let FastMCP attempt to infer the correct transport to use.
@@ -282,7 +270,7 @@ These methods are especially useful for debugging or when you need to access met
282
 
283
  ### Additional Features
284
 
285
- #### Pinging the server
286
 
287
  The client can be used to ping the server to verify connectivity.
288
 
@@ -292,6 +280,62 @@ async with client:
292
  print("Server is reachable")
293
  ```
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  #### Timeouts
296
 
297
  <VersionBadge version="2.3.4" />
 
18
  - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks.
19
  - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory).
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  ### Transports
22
 
23
  Clients must be initialized with a `transport`. You can either provide an already instantiated transport object, or provide a transport source and let FastMCP attempt to infer the correct transport to use.
 
270
 
271
  ### Additional Features
272
 
273
+ #### Pinging the Server
274
 
275
  The client can be used to ping the server to verify connectivity.
276
 
 
280
  print("Server is reachable")
281
  ```
282
 
283
+ #### Session Management
284
+
285
+ When using stdio transports, clients support a `keep_alive` feature (enabled by default) that maintains subprocess sessions between connection contexts. You can manually control this behavior using the client's `close()` method.
286
+
287
+ When `keep_alive=False`, the client will automatically close the session when the context manager exits.
288
+
289
+ <CodeGroup>
290
+ ```python keep_alive=True
291
+ from fastmcp import Client
292
+
293
+ # Client with keep_alive=True (default)
294
+ client = Client("my_mcp_server.py")
295
+
296
+ async def example():
297
+ # First session
298
+ async with client:
299
+ await client.ping()
300
+
301
+ # Second session - uses the same subprocess
302
+ async with client:
303
+ await client.ping()
304
+
305
+ # Manually close the session
306
+ await client.close()
307
+
308
+ # Third session - will start a new subprocess
309
+ async with client:
310
+ await client.ping()
311
+
312
+ asyncio.run(example())
313
+ ```
314
+ ```python keep_alive=False
315
+ from fastmcp import Client
316
+
317
+ # Client with keep_alive=False
318
+ client = Client("my_mcp_server.py", keep_alive=False)
319
+
320
+ async def example():
321
+ # First session
322
+ async with client:
323
+ await client.ping()
324
+
325
+ # Second session - will start a new subprocess
326
+ async with client:
327
+ await client.ping()
328
+
329
+ # Third session - will start a new subprocess
330
+ async with client:
331
+ await client.ping()
332
+
333
+ asyncio.run(example())
334
+ ```
335
+ </CodeGroup>
336
+
337
+
338
+
339
  #### Timeouts
340
 
341
  <VersionBadge version="2.3.4" />
docs/clients/transports.mdx CHANGED
@@ -160,6 +160,63 @@ client = Client(transport)
160
 
161
  These transports manage an MCP server running as a subprocess, communicating with it via standard input (stdin) and standard output (stdout). This is the standard mechanism used by clients like Claude Desktop.
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  ### Python Stdio
164
 
165
  - **Class:** `fastmcp.client.transports.PythonStdioTransport`
@@ -218,7 +275,7 @@ client = Client(node_server_script)
218
  # Option 2: Explicit transport
219
  transport = NodeStdioTransport(
220
  script_path=node_server_script,
221
- node_cmd="node" # Optional: specify path to Node executable
222
  )
223
  client = Client(transport)
224
 
 
160
 
161
  These transports manage an MCP server running as a subprocess, communicating with it via standard input (stdin) and standard output (stdout). This is the standard mechanism used by clients like Claude Desktop.
162
 
163
+ ### Session Management
164
+
165
+ All stdio transports support a `keep_alive` parameter (default: `True`) that controls session persistence across multiple client context managers:
166
+
167
+ - **`keep_alive=True` (default)**: The subprocess and session are maintained between client context exits and re-entries. This improves performance when making multiple separate connections to the same server.
168
+ - **`keep_alive=False`**: A new subprocess is started for each client context, ensuring complete isolation between sessions.
169
+
170
+ When `keep_alive=True`, you can manually close the session using `await client.close()` if needed. This will terminate the subprocess and require a new one to be started on the next connection.
171
+
172
+ <CodeGroup>
173
+ ```python keep_alive=True
174
+ from fastmcp import Client
175
+
176
+ # Client with keep_alive=True (default)
177
+ client = Client("my_mcp_server.py")
178
+
179
+ async def example():
180
+ # First session
181
+ async with client:
182
+ await client.ping()
183
+
184
+ # Second session - uses the same subprocess
185
+ async with client:
186
+ await client.ping()
187
+
188
+ # Manually close the session
189
+ await client.close()
190
+
191
+ # Third session - will start a new subprocess
192
+ async with client:
193
+ await client.ping()
194
+
195
+ asyncio.run(example())
196
+ ```
197
+ ```python keep_alive=False
198
+ from fastmcp import Client
199
+
200
+ # Client with keep_alive=False
201
+ client = Client("my_mcp_server.py", keep_alive=False)
202
+
203
+ async def example():
204
+ # First session
205
+ async with client:
206
+ await client.ping()
207
+
208
+ # Second session - will start a new subprocess
209
+ async with client:
210
+ await client.ping()
211
+
212
+ # Third session - will start a new subprocess
213
+ async with client:
214
+ await client.ping()
215
+
216
+ asyncio.run(example())
217
+ ```
218
+ </CodeGroup>
219
+
220
  ### Python Stdio
221
 
222
  - **Class:** `fastmcp.client.transports.PythonStdioTransport`
 
275
  # Option 2: Explicit transport
276
  transport = NodeStdioTransport(
277
  script_path=node_server_script,
278
+ node_cmd="node", # Optional: specify path to Node executable
279
  )
280
  client = Client(transport)
281
 
src/fastmcp/client/client.py CHANGED
@@ -194,6 +194,7 @@ class Client(Generic[ClientTransportT]):
194
  raise RuntimeError(
195
  "Client is not connected. Use the 'async with client:' context manager first."
196
  )
 
197
  return self._session
198
 
199
  @property
@@ -231,6 +232,8 @@ class Client(Generic[ClientTransportT]):
231
  with anyio.fail_after(self._init_timeout):
232
  self._initialize_result = await self._session.initialize()
233
  yield
 
 
234
  except TimeoutError:
235
  raise RuntimeError("Failed to initialize server session")
236
  finally:
@@ -263,6 +266,9 @@ class Client(Generic[ClientTransportT]):
263
  finally:
264
  self._exit_stack = None
265
 
 
 
 
266
  # --- MCP Client Methods ---
267
 
268
  async def ping(self) -> bool:
 
194
  raise RuntimeError(
195
  "Client is not connected. Use the 'async with client:' context manager first."
196
  )
197
+
198
  return self._session
199
 
200
  @property
 
232
  with anyio.fail_after(self._init_timeout):
233
  self._initialize_result = await self._session.initialize()
234
  yield
235
+ except anyio.ClosedResourceError:
236
+ raise RuntimeError("Server session was closed unexpectedly")
237
  except TimeoutError:
238
  raise RuntimeError("Failed to initialize server session")
239
  finally:
 
266
  finally:
267
  self._exit_stack = None
268
 
269
+ async def close(self):
270
+ await self.transport.close()
271
+
272
  # --- MCP Client Methods ---
273
 
274
  async def ping(self) -> bool:
src/fastmcp/client/transports.py CHANGED
@@ -1,9 +1,11 @@
1
  import abc
 
2
  import contextlib
3
  import datetime
4
  import os
5
  import shutil
6
  import sys
 
7
  from collections.abc import AsyncIterator
8
  from pathlib import Path
9
  from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
@@ -86,11 +88,20 @@ class ClientTransport(abc.ABC):
86
  # Basic representation for subclasses
87
  return f"<{self.__class__.__name__}>"
88
 
 
 
 
 
89
 
90
  class WSTransport(ClientTransport):
91
  """Transport implementation that connects to an MCP server via WebSockets."""
92
 
93
  def __init__(self, url: str | AnyUrl):
 
 
 
 
 
94
  if isinstance(url, AnyUrl):
95
  url = str(url)
96
  if not isinstance(url, str) or not url.startswith("ws"):
@@ -227,6 +238,7 @@ class StdioTransport(ClientTransport):
227
  args: list[str],
228
  env: dict[str, str] | None = None,
229
  cwd: str | None = None,
 
230
  ):
231
  """
232
  Initialize a Stdio transport.
@@ -241,20 +253,81 @@ class StdioTransport(ClientTransport):
241
  self.args = args
242
  self.env = env
243
  self.cwd = cwd
 
 
 
 
 
 
 
 
244
 
245
  @contextlib.asynccontextmanager
246
  async def connect_session(
247
  self, **session_kwargs: Unpack[SessionKwargs]
248
  ) -> AsyncIterator[ClientSession]:
249
- server_params = StdioServerParameters(
250
- command=self.command, args=self.args, env=self.env, cwd=self.cwd
251
- )
252
- async with stdio_client(server_params) as transport:
253
- read_stream, write_stream = transport
254
- async with ClientSession(
255
- read_stream, write_stream, **session_kwargs
256
- ) as session:
257
- yield session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
  def __repr__(self) -> str:
260
  return (
@@ -272,6 +345,7 @@ class PythonStdioTransport(StdioTransport):
272
  env: dict[str, str] | None = None,
273
  cwd: str | None = None,
274
  python_cmd: str = sys.executable,
 
275
  ):
276
  """
277
  Initialize a Python transport.
@@ -293,7 +367,13 @@ class PythonStdioTransport(StdioTransport):
293
  if args:
294
  full_args.extend(args)
295
 
296
- super().__init__(command=python_cmd, args=full_args, env=env, cwd=cwd)
 
 
 
 
 
 
297
  self.script_path = script_path
298
 
299
 
@@ -306,6 +386,7 @@ class FastMCPStdioTransport(StdioTransport):
306
  args: list[str] | None = None,
307
  env: dict[str, str] | None = None,
308
  cwd: str | None = None,
 
309
  ):
310
  script_path = Path(script_path).resolve()
311
  if not script_path.is_file():
@@ -314,7 +395,11 @@ class FastMCPStdioTransport(StdioTransport):
314
  raise ValueError(f"Not a Python script: {script_path}")
315
 
316
  super().__init__(
317
- command="fastmcp", args=["run", str(script_path)], env=env, cwd=cwd
 
 
 
 
318
  )
319
  self.script_path = script_path
320
 
@@ -329,6 +414,7 @@ class NodeStdioTransport(StdioTransport):
329
  env: dict[str, str] | None = None,
330
  cwd: str | None = None,
331
  node_cmd: str = "node",
 
332
  ):
333
  """
334
  Initialize a Node transport.
@@ -350,7 +436,9 @@ class NodeStdioTransport(StdioTransport):
350
  if args:
351
  full_args.extend(args)
352
 
353
- super().__init__(command=node_cmd, args=full_args, env=env, cwd=cwd)
 
 
354
  self.script_path = script_path
355
 
356
 
@@ -366,6 +454,7 @@ class UvxStdioTransport(StdioTransport):
366
  with_packages: list[str] | None = None,
367
  from_package: str | None = None,
368
  env_vars: dict[str, str] | None = None,
 
369
  ):
370
  """
371
  Initialize a Uvx transport.
@@ -405,7 +494,13 @@ class UvxStdioTransport(StdioTransport):
405
  env = os.environ.copy()
406
  env.update(env_vars)
407
 
408
- super().__init__(command="uvx", args=uvx_args, env=env, cwd=project_directory)
 
 
 
 
 
 
409
  self.tool_name = tool_name
410
 
411
 
@@ -419,6 +514,7 @@ class NpxStdioTransport(StdioTransport):
419
  project_directory: str | None = None,
420
  env_vars: dict[str, str] | None = None,
421
  use_package_lock: bool = True,
 
422
  ):
423
  """
424
  Initialize an Npx transport.
@@ -456,7 +552,13 @@ class NpxStdioTransport(StdioTransport):
456
  env = os.environ.copy()
457
  env.update(env_vars)
458
 
459
- super().__init__(command="npx", args=npx_args, env=env, cwd=project_directory)
 
 
 
 
 
 
460
  self.package = package
461
 
462
 
 
1
  import abc
2
+ import asyncio
3
  import contextlib
4
  import datetime
5
  import os
6
  import shutil
7
  import sys
8
+ import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
  from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
 
88
  # Basic representation for subclasses
89
  return f"<{self.__class__.__name__}>"
90
 
91
+ async def close(self):
92
+ """Close the transport."""
93
+ pass
94
+
95
 
96
  class WSTransport(ClientTransport):
97
  """Transport implementation that connects to an MCP server via WebSockets."""
98
 
99
  def __init__(self, url: str | AnyUrl):
100
+ # we never really used this transport, so it can be removed at any time
101
+ warnings.warn(
102
+ "WSTransport is a deprecated MCP transport and will be removed in a future version. Use StreamableHttpTransport instead.",
103
+ DeprecationWarning,
104
+ )
105
  if isinstance(url, AnyUrl):
106
  url = str(url)
107
  if not isinstance(url, str) or not url.startswith("ws"):
 
238
  args: list[str],
239
  env: dict[str, str] | None = None,
240
  cwd: str | None = None,
241
+ keep_alive: bool | None = None,
242
  ):
243
  """
244
  Initialize a Stdio transport.
 
253
  self.args = args
254
  self.env = env
255
  self.cwd = cwd
256
+ if keep_alive is None:
257
+ keep_alive = True
258
+ self.keep_alive = keep_alive
259
+
260
+ self._session: ClientSession | None = None
261
+ self._connect_task: asyncio.Task | None = None
262
+ self._ready_event = asyncio.Event()
263
+ self._stop_event = asyncio.Event()
264
 
265
  @contextlib.asynccontextmanager
266
  async def connect_session(
267
  self, **session_kwargs: Unpack[SessionKwargs]
268
  ) -> AsyncIterator[ClientSession]:
269
+ try:
270
+ await self.connect(**session_kwargs)
271
+ assert self._session is not None
272
+ yield self._session
273
+ finally:
274
+ if not self.keep_alive:
275
+ await self.disconnect()
276
+ else:
277
+ logger.debug("Stdio transport has keep_alive=True, not disconnecting")
278
+
279
+ async def connect(
280
+ self, **session_kwargs: Unpack[SessionKwargs]
281
+ ) -> ClientSession | None:
282
+ if self._connect_task is not None:
283
+ return
284
+
285
+ async def _connect_task():
286
+ async with contextlib.AsyncExitStack() as stack:
287
+ try:
288
+ server_params = StdioServerParameters(
289
+ command=self.command, args=self.args, env=self.env, cwd=self.cwd
290
+ )
291
+ transport = await stack.enter_async_context(
292
+ stdio_client(server_params)
293
+ )
294
+ read_stream, write_stream = transport
295
+ self._session = await stack.enter_async_context(
296
+ ClientSession(read_stream, write_stream, **session_kwargs)
297
+ )
298
+
299
+ logger.debug("Stdio transport connected")
300
+ self._ready_event.set()
301
+
302
+ # Wait until disconnect is requested (stop_event is set)
303
+ await self._stop_event.wait()
304
+ finally:
305
+ # Clean up client on exit
306
+ self._session = None
307
+ logger.debug("Stdio transport disconnected")
308
+
309
+ # start the connection task
310
+ self._connect_task = asyncio.create_task(_connect_task())
311
+ # wait for the client to be ready before returning
312
+ await self._ready_event.wait()
313
+
314
+ async def disconnect(self):
315
+ if self._connect_task is None:
316
+ return
317
+
318
+ # signal the connection task to stop
319
+ self._stop_event.set()
320
+
321
+ # wait for the connection task to finish cleanly
322
+ await self._connect_task
323
+
324
+ # reset variables and events for potential future reconnects
325
+ self._connect_task = None
326
+ self._stop_event = asyncio.Event()
327
+ self._ready_event = asyncio.Event()
328
+
329
+ async def close(self):
330
+ await self.disconnect()
331
 
332
  def __repr__(self) -> str:
333
  return (
 
345
  env: dict[str, str] | None = None,
346
  cwd: str | None = None,
347
  python_cmd: str = sys.executable,
348
+ keep_alive: bool | None = None,
349
  ):
350
  """
351
  Initialize a Python transport.
 
367
  if args:
368
  full_args.extend(args)
369
 
370
+ super().__init__(
371
+ command=python_cmd,
372
+ args=full_args,
373
+ env=env,
374
+ cwd=cwd,
375
+ keep_alive=keep_alive,
376
+ )
377
  self.script_path = script_path
378
 
379
 
 
386
  args: list[str] | None = None,
387
  env: dict[str, str] | None = None,
388
  cwd: str | None = None,
389
+ keep_alive: bool | None = None,
390
  ):
391
  script_path = Path(script_path).resolve()
392
  if not script_path.is_file():
 
395
  raise ValueError(f"Not a Python script: {script_path}")
396
 
397
  super().__init__(
398
+ command="fastmcp",
399
+ args=["run", str(script_path)],
400
+ env=env,
401
+ cwd=cwd,
402
+ keep_alive=keep_alive,
403
  )
404
  self.script_path = script_path
405
 
 
414
  env: dict[str, str] | None = None,
415
  cwd: str | None = None,
416
  node_cmd: str = "node",
417
+ keep_alive: bool | None = None,
418
  ):
419
  """
420
  Initialize a Node transport.
 
436
  if args:
437
  full_args.extend(args)
438
 
439
+ super().__init__(
440
+ command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive
441
+ )
442
  self.script_path = script_path
443
 
444
 
 
454
  with_packages: list[str] | None = None,
455
  from_package: str | None = None,
456
  env_vars: dict[str, str] | None = None,
457
+ keep_alive: bool | None = None,
458
  ):
459
  """
460
  Initialize a Uvx transport.
 
494
  env = os.environ.copy()
495
  env.update(env_vars)
496
 
497
+ super().__init__(
498
+ command="uvx",
499
+ args=uvx_args,
500
+ env=env,
501
+ cwd=project_directory,
502
+ keep_alive=keep_alive,
503
+ )
504
  self.tool_name = tool_name
505
 
506
 
 
514
  project_directory: str | None = None,
515
  env_vars: dict[str, str] | None = None,
516
  use_package_lock: bool = True,
517
+ keep_alive: bool | None = None,
518
  ):
519
  """
520
  Initialize an Npx transport.
 
552
  env = os.environ.copy()
553
  env.update(env_vars)
554
 
555
+ super().__init__(
556
+ command="npx",
557
+ args=npx_args,
558
+ env=env,
559
+ cwd=project_directory,
560
+ keep_alive=keep_alive,
561
+ )
562
  self.package = package
563
 
564
 
tests/client/test_stdio.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import inspect
2
+
3
+ import pytest
4
+ from mcp.types import TextContent
5
+
6
+ from fastmcp import Client
7
+ from fastmcp.client.transports import PythonStdioTransport, StdioTransport
8
+
9
+
10
+ class TestKeepAlive:
11
+ # https://github.com/jlowin/fastmcp/issues/581
12
+
13
+ @pytest.fixture
14
+ def stdio_script(self, tmp_path):
15
+ script = inspect.cleandoc('''
16
+ import os
17
+ from fastmcp import FastMCP
18
+
19
+ mcp = FastMCP()
20
+
21
+ @mcp.tool()
22
+ def pid() -> int:
23
+ """Gets PID of server"""
24
+ return os.getpid()
25
+
26
+ if __name__ == "__main__":
27
+ mcp.run()
28
+ ''')
29
+ script_file = tmp_path / "stdio.py"
30
+ script_file.write_text(script)
31
+ return script_file
32
+
33
+ async def test_keep_alive_default_true(self):
34
+ client = Client(transport=StdioTransport(command="python", args=[""]))
35
+
36
+ assert client.transport.keep_alive is True
37
+
38
+ async def test_keep_alive_set_false(self):
39
+ client = Client(
40
+ transport=StdioTransport(command="python", args=[""], keep_alive=False)
41
+ )
42
+ assert client.transport.keep_alive is False
43
+
44
+ async def test_keep_alive_maintains_session_across_multiple_calls(
45
+ self, stdio_script
46
+ ):
47
+ client = Client(transport=PythonStdioTransport(script_path=stdio_script))
48
+ assert client.transport.keep_alive is True
49
+
50
+ async with client:
51
+ result1 = await client.call_tool("pid")
52
+ assert isinstance(result1[0], TextContent)
53
+ pid1 = int(result1[0].text)
54
+
55
+ async with client:
56
+ result2 = await client.call_tool("pid")
57
+ assert isinstance(result2[0], TextContent)
58
+ pid2 = int(result2[0].text)
59
+
60
+ assert pid1 == pid2
61
+
62
+ async def test_keep_alive_false_starts_new_session_across_multiple_calls(
63
+ self, stdio_script
64
+ ):
65
+ client = Client(
66
+ transport=PythonStdioTransport(script_path=stdio_script, keep_alive=False)
67
+ )
68
+ assert client.transport.keep_alive is False
69
+
70
+ async with client:
71
+ result1 = await client.call_tool("pid")
72
+ assert isinstance(result1[0], TextContent)
73
+ pid1 = int(result1[0].text)
74
+
75
+ async with client:
76
+ result2 = await client.call_tool("pid")
77
+ assert isinstance(result2[0], TextContent)
78
+ pid2 = int(result2[0].text)
79
+
80
+ assert pid1 != pid2
81
+
82
+ async def test_keep_alive_starts_new_session_if_manually_closed(self, stdio_script):
83
+ client = Client(transport=PythonStdioTransport(script_path=stdio_script))
84
+ assert client.transport.keep_alive is True
85
+
86
+ async with client:
87
+ result1 = await client.call_tool("pid")
88
+ assert isinstance(result1[0], TextContent)
89
+ pid1 = int(result1[0].text)
90
+
91
+ await client.close()
92
+
93
+ async with client:
94
+ result2 = await client.call_tool("pid")
95
+ assert isinstance(result2[0], TextContent)
96
+ pid2 = int(result2[0].text)
97
+
98
+ assert pid1 != pid2
99
+
100
+ async def test_keep_alive_maintains_session_if_reentered(self, stdio_script):
101
+ client = Client(transport=PythonStdioTransport(script_path=stdio_script))
102
+ assert client.transport.keep_alive is True
103
+
104
+ async with client:
105
+ result1 = await client.call_tool("pid")
106
+ assert isinstance(result1[0], TextContent)
107
+ pid1 = int(result1[0].text)
108
+
109
+ async with client:
110
+ result2 = await client.call_tool("pid")
111
+ assert isinstance(result2[0], TextContent)
112
+ pid2 = int(result2[0].text)
113
+
114
+ result3 = await client.call_tool("pid")
115
+ assert isinstance(result3[0], TextContent)
116
+ pid3 = int(result3[0].text)
117
+
118
+ assert pid1 == pid2 == pid3
119
+
120
+ async def test_close_session_and_try_to_use_client_raises_error(self, stdio_script):
121
+ client = Client(transport=PythonStdioTransport(script_path=stdio_script))
122
+ assert client.transport.keep_alive is True
123
+
124
+ with pytest.raises(
125
+ RuntimeError, match="Server session was closed unexpectedly"
126
+ ):
127
+ async with client:
128
+ await client.close()
129
+ await client.call_tool("pid")