Jeremiah Lowin commited on
Commit
f64719f
·
unverified ·
2 Parent(s): 94ed28966bb9ef

Merge pull request #624 from jlowin/keep-alive

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,29 @@ 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
+ ```python
290
+ from fastmcp import Client
291
+
292
+ client = Client("my_mcp_server.py") # keep_alive=True by default
293
+
294
+ async def example():
295
+ async with client:
296
+ await client.ping()
297
+
298
+ async with client:
299
+ await client.ping() # Same subprocess as above
300
+ ```
301
+
302
+ <Note>
303
+ For detailed examples and configuration options, see [Session Management in Transports](/clients/transports#session-management).
304
+ </Note>
305
+
306
  #### Timeouts
307
 
308
  <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,11 @@ 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
+ self._session = None
272
+ self._initialize_result = None
273
+
274
  # --- MCP Client Methods ---
275
 
276
  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,21 @@ 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 +239,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.
@@ -236,25 +249,90 @@ class StdioTransport(ClientTransport):
236
  args: The arguments to pass to the command
237
  env: Environment variables to set for the subprocess
238
  cwd: Current working directory for the subprocess
 
 
 
 
239
  """
240
  self.command = command
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 +350,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.
@@ -282,6 +361,10 @@ class PythonStdioTransport(StdioTransport):
282
  env: Environment variables to set for the subprocess
283
  cwd: Current working directory for the subprocess
284
  python_cmd: Python command to use (default: "python")
 
 
 
 
285
  """
286
  script_path = Path(script_path).resolve()
287
  if not script_path.is_file():
@@ -293,7 +376,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 +395,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 +404,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 +423,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.
@@ -339,6 +434,10 @@ class NodeStdioTransport(StdioTransport):
339
  env: Environment variables to set for the subprocess
340
  cwd: Current working directory for the subprocess
341
  node_cmd: Node.js command to use (default: "node")
 
 
 
 
342
  """
343
  script_path = Path(script_path).resolve()
344
  if not script_path.is_file():
@@ -350,7 +449,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 +467,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.
@@ -378,6 +480,10 @@ class UvxStdioTransport(StdioTransport):
378
  with_packages: Additional packages to include
379
  from_package: Package to install the tool from
380
  env_vars: Additional environment variables
 
 
 
 
381
  """
382
  # Basic validation
383
  if project_directory and not Path(project_directory).exists():
@@ -405,7 +511,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 +531,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.
@@ -429,6 +542,10 @@ class NpxStdioTransport(StdioTransport):
429
  project_directory: Project directory with package.json
430
  env_vars: Additional environment variables
431
  use_package_lock: Whether to use package-lock.json (--prefer-offline)
 
 
 
 
432
  """
433
  # verify npx is installed
434
  if shutil.which("npx") is None:
@@ -456,7 +573,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
+ stacklevel=2,
105
+ )
106
  if isinstance(url, AnyUrl):
107
  url = str(url)
108
  if not isinstance(url, str) or not url.startswith("ws"):
 
239
  args: list[str],
240
  env: dict[str, str] | None = None,
241
  cwd: str | None = None,
242
+ keep_alive: bool | None = None,
243
  ):
244
  """
245
  Initialize a Stdio transport.
 
249
  args: The arguments to pass to the command
250
  env: Environment variables to set for the subprocess
251
  cwd: Current working directory for the subprocess
252
+ keep_alive: Whether to keep the subprocess alive between connections.
253
+ Defaults to True. When True, the subprocess remains active
254
+ after the connection context exits, allowing reuse in
255
+ subsequent connections.
256
  """
257
  self.command = command
258
  self.args = args
259
  self.env = env
260
  self.cwd = cwd
261
+ if keep_alive is None:
262
+ keep_alive = True
263
+ self.keep_alive = keep_alive
264
+
265
+ self._session: ClientSession | None = None
266
+ self._connect_task: asyncio.Task | None = None
267
+ self._ready_event = asyncio.Event()
268
+ self._stop_event = asyncio.Event()
269
 
270
  @contextlib.asynccontextmanager
271
  async def connect_session(
272
  self, **session_kwargs: Unpack[SessionKwargs]
273
  ) -> AsyncIterator[ClientSession]:
274
+ try:
275
+ await self.connect(**session_kwargs)
276
+ assert self._session is not None
277
+ yield self._session
278
+ finally:
279
+ if not self.keep_alive:
280
+ await self.disconnect()
281
+ else:
282
+ logger.debug("Stdio transport has keep_alive=True, not disconnecting")
283
+
284
+ async def connect(
285
+ self, **session_kwargs: Unpack[SessionKwargs]
286
+ ) -> ClientSession | None:
287
+ if self._connect_task is not None:
288
+ return
289
+
290
+ async def _connect_task():
291
+ async with contextlib.AsyncExitStack() as stack:
292
+ try:
293
+ server_params = StdioServerParameters(
294
+ command=self.command, args=self.args, env=self.env, cwd=self.cwd
295
+ )
296
+ transport = await stack.enter_async_context(
297
+ stdio_client(server_params)
298
+ )
299
+ read_stream, write_stream = transport
300
+ self._session = await stack.enter_async_context(
301
+ ClientSession(read_stream, write_stream, **session_kwargs)
302
+ )
303
+
304
+ logger.debug("Stdio transport connected")
305
+ self._ready_event.set()
306
+
307
+ # Wait until disconnect is requested (stop_event is set)
308
+ await self._stop_event.wait()
309
+ finally:
310
+ # Clean up client on exit
311
+ self._session = None
312
+ logger.debug("Stdio transport disconnected")
313
+
314
+ # start the connection task
315
+ self._connect_task = asyncio.create_task(_connect_task())
316
+ # wait for the client to be ready before returning
317
+ await self._ready_event.wait()
318
+
319
+ async def disconnect(self):
320
+ if self._connect_task is None:
321
+ return
322
+
323
+ # signal the connection task to stop
324
+ self._stop_event.set()
325
+
326
+ # wait for the connection task to finish cleanly
327
+ await self._connect_task
328
+
329
+ # reset variables and events for potential future reconnects
330
+ self._connect_task = None
331
+ self._stop_event = asyncio.Event()
332
+ self._ready_event = asyncio.Event()
333
+
334
+ async def close(self):
335
+ await self.disconnect()
336
 
337
  def __repr__(self) -> str:
338
  return (
 
350
  env: dict[str, str] | None = None,
351
  cwd: str | None = None,
352
  python_cmd: str = sys.executable,
353
+ keep_alive: bool | None = None,
354
  ):
355
  """
356
  Initialize a Python transport.
 
361
  env: Environment variables to set for the subprocess
362
  cwd: Current working directory for the subprocess
363
  python_cmd: Python command to use (default: "python")
364
+ keep_alive: Whether to keep the subprocess alive between connections.
365
+ Defaults to True. When True, the subprocess remains active
366
+ after the connection context exits, allowing reuse in
367
+ subsequent connections.
368
  """
369
  script_path = Path(script_path).resolve()
370
  if not script_path.is_file():
 
376
  if args:
377
  full_args.extend(args)
378
 
379
+ super().__init__(
380
+ command=python_cmd,
381
+ args=full_args,
382
+ env=env,
383
+ cwd=cwd,
384
+ keep_alive=keep_alive,
385
+ )
386
  self.script_path = script_path
387
 
388
 
 
395
  args: list[str] | None = None,
396
  env: dict[str, str] | None = None,
397
  cwd: str | None = None,
398
+ keep_alive: bool | None = None,
399
  ):
400
  script_path = Path(script_path).resolve()
401
  if not script_path.is_file():
 
404
  raise ValueError(f"Not a Python script: {script_path}")
405
 
406
  super().__init__(
407
+ command="fastmcp",
408
+ args=["run", str(script_path)],
409
+ env=env,
410
+ cwd=cwd,
411
+ keep_alive=keep_alive,
412
  )
413
  self.script_path = script_path
414
 
 
423
  env: dict[str, str] | None = None,
424
  cwd: str | None = None,
425
  node_cmd: str = "node",
426
+ keep_alive: bool | None = None,
427
  ):
428
  """
429
  Initialize a Node transport.
 
434
  env: Environment variables to set for the subprocess
435
  cwd: Current working directory for the subprocess
436
  node_cmd: Node.js command to use (default: "node")
437
+ keep_alive: Whether to keep the subprocess alive between connections.
438
+ Defaults to True. When True, the subprocess remains active
439
+ after the connection context exits, allowing reuse in
440
+ subsequent connections.
441
  """
442
  script_path = Path(script_path).resolve()
443
  if not script_path.is_file():
 
449
  if args:
450
  full_args.extend(args)
451
 
452
+ super().__init__(
453
+ command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive
454
+ )
455
  self.script_path = script_path
456
 
457
 
 
467
  with_packages: list[str] | None = None,
468
  from_package: str | None = None,
469
  env_vars: dict[str, str] | None = None,
470
+ keep_alive: bool | None = None,
471
  ):
472
  """
473
  Initialize a Uvx transport.
 
480
  with_packages: Additional packages to include
481
  from_package: Package to install the tool from
482
  env_vars: Additional environment variables
483
+ keep_alive: Whether to keep the subprocess alive between connections.
484
+ Defaults to True. When True, the subprocess remains active
485
+ after the connection context exits, allowing reuse in
486
+ subsequent connections.
487
  """
488
  # Basic validation
489
  if project_directory and not Path(project_directory).exists():
 
511
  env = os.environ.copy()
512
  env.update(env_vars)
513
 
514
+ super().__init__(
515
+ command="uvx",
516
+ args=uvx_args,
517
+ env=env,
518
+ cwd=project_directory,
519
+ keep_alive=keep_alive,
520
+ )
521
  self.tool_name = tool_name
522
 
523
 
 
531
  project_directory: str | None = None,
532
  env_vars: dict[str, str] | None = None,
533
  use_package_lock: bool = True,
534
+ keep_alive: bool | None = None,
535
  ):
536
  """
537
  Initialize an Npx transport.
 
542
  project_directory: Project directory with package.json
543
  env_vars: Additional environment variables
544
  use_package_lock: Whether to use package-lock.json (--prefer-offline)
545
+ keep_alive: Whether to keep the subprocess alive between connections.
546
+ Defaults to True. When True, the subprocess remains active
547
+ after the connection context exits, allowing reuse in
548
+ subsequent connections.
549
  """
550
  # verify npx is installed
551
  if shutil.which("npx") is None:
 
573
  env = os.environ.copy()
574
  env.update(env_vars)
575
 
576
+ super().__init__(
577
+ command="npx",
578
+ args=npx_args,
579
+ env=env,
580
+ cwd=project_directory,
581
+ keep_alive=keep_alive,
582
+ )
583
  self.package = package
584
 
585
 
tests/client/test_stdio.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ async with client:
125
+ await client.close()
126
+ with pytest.raises(RuntimeError, match="Client is not connected"):
127
+ await client.call_tool("pid")