fastmcp 2.10.4__py3-none-any.whl → 2.10.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/__init__.py CHANGED
@@ -1,10 +1,15 @@
1
1
  """FastMCP - An ergonomic MCP interface."""
2
2
 
3
3
  import warnings
4
- from importlib.metadata import version
4
+ from importlib.metadata import version as _version
5
5
  from fastmcp.settings import Settings
6
+ from fastmcp.utilities.logging import configure_logging as _configure_logging
6
7
 
7
8
  settings = Settings()
9
+ _configure_logging(
10
+ level=settings.log_level,
11
+ enable_rich_tracebacks=settings.enable_rich_tracebacks,
12
+ )
8
13
 
9
14
  from fastmcp.server.server import FastMCP
10
15
  from fastmcp.server.context import Context
@@ -13,7 +18,7 @@ import fastmcp.server
13
18
  from fastmcp.client import Client
14
19
  from . import client
15
20
 
16
- __version__ = version("fastmcp")
21
+ __version__ = _version("fastmcp")
17
22
 
18
23
 
19
24
  # ensure deprecation warnings are displayed by default
@@ -5,7 +5,7 @@ import cyclopts
5
5
  from .claude_code import claude_code_command
6
6
  from .claude_desktop import claude_desktop_command
7
7
  from .cursor import cursor_command
8
- from .mcp_config import mcp_config_command
8
+ from .mcp_json import mcp_json_command
9
9
 
10
10
  # Create a cyclopts app for install subcommands
11
11
  install_app = cyclopts.App(
@@ -17,4 +17,4 @@ install_app = cyclopts.App(
17
17
  install_app.command(claude_code_command, name="claude-code")
18
18
  install_app.command(claude_desktop_command, name="claude-desktop")
19
19
  install_app.command(cursor_command, name="cursor")
20
- install_app.command(mcp_config_command, name="mcp-json")
20
+ install_app.command(mcp_json_command, name="mcp-json")
@@ -1,5 +1,6 @@
1
1
  """Claude Code integration for FastMCP install using Cyclopts."""
2
2
 
3
+ import shutil
3
4
  import subprocess
4
5
  import sys
5
6
  from pathlib import Path
@@ -16,22 +17,50 @@ logger = get_logger(__name__)
16
17
 
17
18
 
18
19
  def find_claude_command() -> str | None:
19
- """Find the Claude Code CLI command."""
20
- # Check the default installation location
21
- default_path = Path.home() / ".claude" / "local" / "claude"
22
- if default_path.exists():
20
+ """Find the Claude Code CLI command.
21
+
22
+ Checks common installation locations since 'claude' is often a shell alias
23
+ that doesn't work with subprocess calls.
24
+ """
25
+ # First try shutil.which() in case it's a real executable in PATH
26
+ claude_in_path = shutil.which("claude")
27
+ if claude_in_path:
23
28
  try:
24
29
  result = subprocess.run(
25
- [str(default_path), "--version"],
30
+ [claude_in_path, "--version"],
26
31
  check=True,
27
32
  capture_output=True,
28
33
  text=True,
29
34
  )
30
35
  if "Claude Code" in result.stdout:
31
- return str(default_path)
36
+ return claude_in_path
32
37
  except (subprocess.CalledProcessError, FileNotFoundError):
33
38
  pass
34
39
 
40
+ # Check common installation locations (aliases don't work with subprocess)
41
+ potential_paths = [
42
+ # Default Claude Code installation location (after migration)
43
+ Path.home() / ".claude" / "local" / "claude",
44
+ # npm global installation on macOS/Linux (default)
45
+ Path("/usr/local/bin/claude"),
46
+ # npm global installation with custom prefix
47
+ Path.home() / ".npm-global" / "bin" / "claude",
48
+ ]
49
+
50
+ for path in potential_paths:
51
+ if path.exists():
52
+ try:
53
+ result = subprocess.run(
54
+ [str(path), "--version"],
55
+ check=True,
56
+ capture_output=True,
57
+ text=True,
58
+ )
59
+ if "Claude Code" in result.stdout:
60
+ return str(path)
61
+ except (subprocess.CalledProcessError, FileNotFoundError):
62
+ continue
63
+
35
64
  return None
36
65
 
37
66
 
@@ -16,7 +16,7 @@ from .shared import process_common_args
16
16
  logger = get_logger(__name__)
17
17
 
18
18
 
19
- def install_mcp_config(
19
+ def install_mcp_json(
20
20
  file: Path,
21
21
  server_object: str | None,
22
22
  name: str,
@@ -65,15 +65,18 @@ def install_mcp_config(
65
65
  # Add fastmcp run command
66
66
  args.extend(["fastmcp", "run", server_spec])
67
67
 
68
- # Build MCP server configuration (just the server object, not the wrapper)
69
- config = {
68
+ # Build MCP server configuration
69
+ server_config = {
70
70
  "command": "uv",
71
71
  "args": args,
72
72
  }
73
73
 
74
74
  # Add environment variables if provided
75
75
  if env_vars:
76
- config["env"] = env_vars
76
+ server_config["env"] = env_vars
77
+
78
+ # Wrap with server name as root key
79
+ config = {name: server_config}
77
80
 
78
81
  # Convert to JSON
79
82
  json_output = json.dumps(config, indent=2)
@@ -93,13 +96,13 @@ def install_mcp_config(
93
96
  return False
94
97
 
95
98
 
96
- def mcp_config_command(
99
+ def mcp_json_command(
97
100
  server_spec: str,
98
101
  *,
99
102
  server_name: Annotated[
100
103
  str | None,
101
104
  cyclopts.Parameter(
102
- name=["--server-name", "-n"],
105
+ name=["--name", "-n"],
103
106
  help="Custom name for the server in MCP config",
104
107
  ),
105
108
  ] = None,
@@ -151,7 +154,7 @@ def mcp_config_command(
151
154
  server_spec, server_name, with_packages, env_vars, env_file
152
155
  )
153
156
 
154
- success = install_mcp_config(
157
+ success = install_mcp_json(
155
158
  file=file,
156
159
  server_object=server_object,
157
160
  name=name,
@@ -78,7 +78,7 @@ class PromptManager:
78
78
  except Exception as e:
79
79
  # Skip failed mounts silently, matches existing behavior
80
80
  logger.warning(
81
- f"Failed to get prompts from mounted server '{mounted.prefix}': {e}"
81
+ f"Failed to get prompts from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
82
82
  )
83
83
  continue
84
84
 
@@ -109,7 +109,7 @@ class ResourceManager:
109
109
  except Exception as e:
110
110
  # Skip failed mounts silently, matches existing behavior
111
111
  logger.warning(
112
- f"Failed to get resources from mounted server '{mounted.prefix}': {e}"
112
+ f"Failed to get resources from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
113
113
  )
114
114
  continue
115
115
 
@@ -157,7 +157,7 @@ class ResourceManager:
157
157
  except Exception as e:
158
158
  # Skip failed mounts silently, matches existing behavior
159
159
  logger.warning(
160
- f"Failed to get templates from mounted server '{mounted.prefix}': {e}"
160
+ f"Failed to get templates from server: {mounted.server.name!r}, mounted at: {mounted.prefix!r}: {e}"
161
161
  )
162
162
  continue
163
163
 
fastmcp/server/context.py CHANGED
@@ -16,6 +16,7 @@ from mcp.shared.context import RequestContext
16
16
  from mcp.types import (
17
17
  ContentBlock,
18
18
  CreateMessageResult,
19
+ IncludeContext,
19
20
  ModelHint,
20
21
  ModelPreferences,
21
22
  Root,
@@ -272,6 +273,7 @@ class Context:
272
273
  self,
273
274
  messages: str | list[str | SamplingMessage],
274
275
  system_prompt: str | None = None,
276
+ include_context: IncludeContext | None = None,
275
277
  temperature: float | None = None,
276
278
  max_tokens: int | None = None,
277
279
  model_preferences: ModelPreferences | str | list[str] | None = None,
@@ -304,6 +306,7 @@ class Context:
304
306
  result: CreateMessageResult = await self.session.create_message(
305
307
  messages=sampling_messages,
306
308
  system_prompt=system_prompt,
309
+ include_context=include_context,
307
310
  temperature=temperature,
308
311
  max_tokens=max_tokens,
309
312
  model_preferences=self._parse_model_preferences(model_preferences),
@@ -2,18 +2,10 @@ from .middleware import (
2
2
  Middleware,
3
3
  MiddlewareContext,
4
4
  CallNext,
5
- ListToolsResult,
6
- ListResourcesResult,
7
- ListResourceTemplatesResult,
8
- ListPromptsResult,
9
5
  )
10
6
 
11
7
  __all__ = [
12
8
  "Middleware",
13
9
  "MiddlewareContext",
14
10
  "CallNext",
15
- "ListToolsResult",
16
- "ListResourcesResult",
17
- "ListResourceTemplatesResult",
18
- "ListPromptsResult",
19
11
  ]
@@ -29,10 +29,6 @@ __all__ = [
29
29
  "Middleware",
30
30
  "MiddlewareContext",
31
31
  "CallNext",
32
- "ListToolsResult",
33
- "ListResourcesResult",
34
- "ListResourceTemplatesResult",
35
- "ListPromptsResult",
36
32
  ]
37
33
 
38
34
  logger = logging.getLogger(__name__)
@@ -62,26 +58,6 @@ ServerResultT = TypeVar(
62
58
  )
63
59
 
64
60
 
65
- @dataclass(kw_only=True)
66
- class ListToolsResult:
67
- tools: dict[str, Tool]
68
-
69
-
70
- @dataclass(kw_only=True)
71
- class ListResourcesResult:
72
- resources: list[Resource]
73
-
74
-
75
- @dataclass(kw_only=True)
76
- class ListResourceTemplatesResult:
77
- resource_templates: list[ResourceTemplate]
78
-
79
-
80
- @dataclass(kw_only=True)
81
- class ListPromptsResult:
82
- prompts: list[Prompt]
83
-
84
-
85
61
  @runtime_checkable
86
62
  class ServerResultProtocol(Protocol[ServerResultT]):
87
63
  root: ServerResultT
@@ -212,29 +188,27 @@ class Middleware:
212
188
  async def on_list_tools(
213
189
  self,
214
190
  context: MiddlewareContext[mt.ListToolsRequest],
215
- call_next: CallNext[mt.ListToolsRequest, ListToolsResult],
216
- ) -> ListToolsResult:
191
+ call_next: CallNext[mt.ListToolsRequest, list[Tool]],
192
+ ) -> list[Tool]:
217
193
  return await call_next(context)
218
194
 
219
195
  async def on_list_resources(
220
196
  self,
221
197
  context: MiddlewareContext[mt.ListResourcesRequest],
222
- call_next: CallNext[mt.ListResourcesRequest, ListResourcesResult],
223
- ) -> ListResourcesResult:
198
+ call_next: CallNext[mt.ListResourcesRequest, list[Resource]],
199
+ ) -> list[Resource]:
224
200
  return await call_next(context)
225
201
 
226
202
  async def on_list_resource_templates(
227
203
  self,
228
204
  context: MiddlewareContext[mt.ListResourceTemplatesRequest],
229
- call_next: CallNext[
230
- mt.ListResourceTemplatesRequest, ListResourceTemplatesResult
231
- ],
232
- ) -> ListResourceTemplatesResult:
205
+ call_next: CallNext[mt.ListResourceTemplatesRequest, list[ResourceTemplate]],
206
+ ) -> list[ResourceTemplate]:
233
207
  return await call_next(context)
234
208
 
235
209
  async def on_list_prompts(
236
210
  self,
237
211
  context: MiddlewareContext[mt.ListPromptsRequest],
238
- call_next: CallNext[mt.ListPromptsRequest, ListPromptsResult],
239
- ) -> ListPromptsResult:
212
+ call_next: CallNext[mt.ListPromptsRequest, list[Prompt]],
213
+ ) -> list[Prompt]:
240
214
  return await call_next(context)
fastmcp/server/openapi.py CHANGED
@@ -29,6 +29,7 @@ from fastmcp.utilities.openapi import (
29
29
  _combine_schemas,
30
30
  extract_output_schema_from_responses,
31
31
  format_array_parameter,
32
+ format_deep_object_parameter,
32
33
  format_description_with_responses,
33
34
  )
34
35
 
@@ -261,19 +262,45 @@ class OpenAPITool(Tool):
261
262
  async def run(self, arguments: dict[str, Any]) -> ToolResult:
262
263
  """Execute the HTTP request based on the route configuration."""
263
264
 
265
+ # Create mapping from suffixed parameter names back to original names and locations
266
+ # This handles parameter collisions where suffixes were added during schema generation
267
+ param_mapping = {} # suffixed_name -> (original_name, location)
268
+
269
+ # First, check if we have request body properties to detect collisions
270
+ body_props = set()
271
+ if self._route.request_body and self._route.request_body.content_schema:
272
+ content_type = next(iter(self._route.request_body.content_schema))
273
+ body_schema = self._route.request_body.content_schema[content_type]
274
+ body_props = set(body_schema.get("properties", {}).keys())
275
+
276
+ # Build parameter mapping for potentially suffixed parameters
277
+ for param in self._route.parameters:
278
+ original_name = param.name
279
+ suffixed_name = f"{param.name}__{param.location}"
280
+
281
+ # If parameter name collides with body property, it would have been suffixed
282
+ if param.name in body_props:
283
+ param_mapping[suffixed_name] = (original_name, param.location)
284
+ # Also map original name for backward compatibility when no collision
285
+ param_mapping[original_name] = (original_name, param.location)
286
+
264
287
  # Prepare URL
265
288
  path = self._route.path
266
289
 
267
- # Replace path parameters with values from kwargs
268
- # Path parameters should never be None as they're typically required
269
- # but we'll handle that case anyway
270
- path_params = {
271
- p.name: arguments.get(p.name)
272
- for p in self._route.parameters
273
- if p.location == "path"
274
- and p.name in arguments
275
- and arguments.get(p.name) is not None
276
- }
290
+ # Replace path parameters with values from arguments
291
+ # Look for both original and suffixed parameter names
292
+ path_params = {}
293
+ for p in self._route.parameters:
294
+ if p.location == "path":
295
+ # Try suffixed name first, then original name
296
+ suffixed_name = f"{p.name}__{p.location}"
297
+ if (
298
+ suffixed_name in arguments
299
+ and arguments.get(suffixed_name) is not None
300
+ ):
301
+ path_params[p.name] = arguments[suffixed_name]
302
+ elif p.name in arguments and arguments.get(p.name) is not None:
303
+ path_params[p.name] = arguments[p.name]
277
304
 
278
305
  # Ensure all path parameters are provided
279
306
  required_path_params = {
@@ -312,35 +339,82 @@ class OpenAPITool(Tool):
312
339
  # Prepare query parameters - filter out None and empty strings
313
340
  query_params = {}
314
341
  for p in self._route.parameters:
315
- if (
316
- p.location == "query"
317
- and p.name in arguments
318
- and arguments.get(p.name) is not None
319
- and arguments.get(p.name) != ""
320
- ):
321
- param_value = arguments.get(p.name)
322
-
323
- # Format array query parameters as comma-separated strings
324
- # following OpenAPI form style (default for query parameters)
325
- if isinstance(param_value, list) and p.schema_.get("type") == "array":
326
- # Get explode parameter from the parameter info, default is True for query parameters
327
- # If explode is True, the array is serialized as separate parameters
328
- # If explode is False, the array is serialized as a comma-separated string
329
- explode = p.explode if p.explode is not None else True
330
-
331
- if explode:
332
- # When explode=True, we pass the array directly, which HTTPX will serialize
333
- # as multiple parameters with the same name
334
- query_params[p.name] = param_value
335
- else:
336
- # Format array as comma-separated string when explode=False
337
- formatted_value = format_array_parameter(
338
- param_value, p.name, is_query_parameter=True
339
- )
340
- query_params[p.name] = formatted_value
342
+ if p.location == "query":
343
+ # Try suffixed name first, then original name
344
+ suffixed_name = f"{p.name}__{p.location}"
345
+ param_value = None
346
+
347
+ suffixed_value = arguments.get(suffixed_name)
348
+ if (
349
+ suffixed_name in arguments
350
+ and suffixed_value is not None
351
+ and suffixed_value != ""
352
+ and not (
353
+ isinstance(suffixed_value, list | dict)
354
+ and len(suffixed_value) == 0
355
+ )
356
+ ):
357
+ param_value = arguments[suffixed_name]
341
358
  else:
342
- # Non-array parameters are passed as is
343
- query_params[p.name] = param_value
359
+ name_value = arguments.get(p.name)
360
+ if (
361
+ p.name in arguments
362
+ and name_value is not None
363
+ and name_value != ""
364
+ and not (
365
+ isinstance(name_value, list | dict) and len(name_value) == 0
366
+ )
367
+ ):
368
+ param_value = arguments[p.name]
369
+
370
+ if param_value is not None:
371
+ # Handle different parameter styles and types
372
+ param_style = (
373
+ p.style or "form"
374
+ ) # Default style for query parameters is "form"
375
+ param_explode = (
376
+ p.explode if p.explode is not None else True
377
+ ) # Default explode for query is True
378
+
379
+ # Handle deepObject style for object parameters
380
+ if (
381
+ param_style == "deepObject"
382
+ and isinstance(param_value, dict)
383
+ and len(param_value) > 0
384
+ ):
385
+ if param_explode:
386
+ # deepObject with explode=true: object properties become separate parameters
387
+ # e.g., target[id]=123&target[type]=user
388
+ deep_obj_params = format_deep_object_parameter(
389
+ param_value, p.name
390
+ )
391
+ query_params.update(deep_obj_params)
392
+ else:
393
+ # deepObject with explode=false is not commonly used, fallback to JSON
394
+ logger.warning(
395
+ f"deepObject style with explode=false for parameter '{p.name}' is not standard. "
396
+ f"Using JSON serialization fallback."
397
+ )
398
+ query_params[p.name] = json.dumps(param_value)
399
+ # Handle array parameters with form style (default)
400
+ elif (
401
+ isinstance(param_value, list)
402
+ and p.schema_.get("type") == "array"
403
+ and len(param_value) > 0
404
+ ):
405
+ if param_explode:
406
+ # When explode=True, we pass the array directly, which HTTPX will serialize
407
+ # as multiple parameters with the same name
408
+ query_params[p.name] = param_value
409
+ else:
410
+ # Format array as comma-separated string when explode=False
411
+ formatted_value = format_array_parameter(
412
+ param_value, p.name, is_query_parameter=True
413
+ )
414
+ query_params[p.name] = formatted_value
415
+ else:
416
+ # Non-array, non-deepObject parameters are passed as is
417
+ query_params[p.name] = param_value
344
418
 
345
419
  # Prepare headers - fix typing by ensuring all values are strings
346
420
  headers = {}
@@ -348,12 +422,21 @@ class OpenAPITool(Tool):
348
422
  # Start with OpenAPI-defined header parameters
349
423
  openapi_headers = {}
350
424
  for p in self._route.parameters:
351
- if (
352
- p.location == "header"
353
- and p.name in arguments
354
- and arguments[p.name] is not None
355
- ):
356
- openapi_headers[p.name.lower()] = str(arguments[p.name])
425
+ if p.location == "header":
426
+ # Try suffixed name first, then original name
427
+ suffixed_name = f"{p.name}__{p.location}"
428
+ param_value = None
429
+
430
+ if (
431
+ suffixed_name in arguments
432
+ and arguments.get(suffixed_name) is not None
433
+ ):
434
+ param_value = arguments[suffixed_name]
435
+ elif p.name in arguments and arguments.get(p.name) is not None:
436
+ param_value = arguments[p.name]
437
+
438
+ if param_value is not None:
439
+ openapi_headers[p.name.lower()] = str(param_value)
357
440
  headers.update(openapi_headers)
358
441
 
359
442
  # Add headers from the current MCP client HTTP request (these take precedence)
@@ -363,16 +446,20 @@ class OpenAPITool(Tool):
363
446
  # Prepare request body
364
447
  json_data = None
365
448
  if self._route.request_body and self._route.request_body.content_schema:
366
- # Extract body parameters, excluding path/query/header params that were already used
367
- path_query_header_params = {
368
- p.name
369
- for p in self._route.parameters
370
- if p.location in ("path", "query", "header")
371
- }
449
+ # Extract body parameters with collision-aware logic
450
+ # Exclude all parameter names that belong to path/query/header locations
451
+ params_to_exclude = set()
452
+
453
+ for p in self._route.parameters:
454
+ if (
455
+ p.name in body_props
456
+ ): # This parameter had a collision, so it was suffixed
457
+ params_to_exclude.add(f"{p.name}__{p.location}")
458
+ else: # No collision, parameter keeps original name but should still be excluded from body
459
+ params_to_exclude.add(p.name)
460
+
372
461
  body_params = {
373
- k: v
374
- for k, v in arguments.items()
375
- if k not in path_query_header_params and k != "context"
462
+ k: v for k, v in arguments.items() if k not in params_to_exclude
376
463
  }
377
464
 
378
465
  if body_params:
fastmcp/server/proxy.py CHANGED
@@ -36,6 +36,7 @@ from fastmcp.server.dependencies import get_context
36
36
  from fastmcp.server.server import FastMCP
37
37
  from fastmcp.tools.tool import Tool, ToolResult
38
38
  from fastmcp.tools.tool_manager import ToolManager
39
+ from fastmcp.utilities.components import MirroredComponent
39
40
  from fastmcp.utilities.logging import get_logger
40
41
 
41
42
  if TYPE_CHECKING:
@@ -226,7 +227,7 @@ class ProxyPromptManager(PromptManager):
226
227
  return result
227
228
 
228
229
 
229
- class ProxyTool(Tool):
230
+ class ProxyTool(Tool, MirroredComponent):
230
231
  """
231
232
  A Tool that represents and executes a tool on a remote server.
232
233
  """
@@ -245,6 +246,7 @@ class ProxyTool(Tool):
245
246
  parameters=mcp_tool.inputSchema,
246
247
  annotations=mcp_tool.annotations,
247
248
  output_schema=mcp_tool.outputSchema,
249
+ _mirrored=True,
248
250
  )
249
251
 
250
252
  async def run(
@@ -266,7 +268,7 @@ class ProxyTool(Tool):
266
268
  )
267
269
 
268
270
 
269
- class ProxyResource(Resource):
271
+ class ProxyResource(Resource, MirroredComponent):
270
272
  """
271
273
  A Resource that represents and reads a resource from a remote server.
272
274
  """
@@ -298,6 +300,7 @@ class ProxyResource(Resource):
298
300
  name=mcp_resource.name,
299
301
  description=mcp_resource.description,
300
302
  mime_type=mcp_resource.mimeType or "text/plain",
303
+ _mirrored=True,
301
304
  )
302
305
 
303
306
  async def read(self) -> str | bytes:
@@ -315,7 +318,7 @@ class ProxyResource(Resource):
315
318
  raise ResourceError(f"Unsupported content type: {type(result[0])}")
316
319
 
317
320
 
318
- class ProxyTemplate(ResourceTemplate):
321
+ class ProxyTemplate(ResourceTemplate, MirroredComponent):
319
322
  """
320
323
  A ResourceTemplate that represents and creates resources from a remote server template.
321
324
  """
@@ -336,6 +339,7 @@ class ProxyTemplate(ResourceTemplate):
336
339
  description=mcp_template.description,
337
340
  mime_type=mcp_template.mimeType or "text/plain",
338
341
  parameters={}, # Remote templates don't have local parameters
342
+ _mirrored=True,
339
343
  )
340
344
 
341
345
  async def create_resource(
@@ -371,7 +375,7 @@ class ProxyTemplate(ResourceTemplate):
371
375
  )
372
376
 
373
377
 
374
- class ProxyPrompt(Prompt):
378
+ class ProxyPrompt(Prompt, MirroredComponent):
375
379
  """
376
380
  A Prompt that represents and renders a prompt from a remote server.
377
381
  """
@@ -400,6 +404,7 @@ class ProxyPrompt(Prompt):
400
404
  name=mcp_prompt.name,
401
405
  description=mcp_prompt.description,
402
406
  arguments=arguments,
407
+ _mirrored=True,
403
408
  )
404
409
 
405
410
  async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
@@ -575,3 +580,45 @@ class ProxyClient(Client[ClientTransportT]):
575
580
  """
576
581
  ctx = get_context()
577
582
  await ctx.report_progress(progress, total, message)
583
+
584
+
585
+ class StatefulProxyClient(ProxyClient[ClientTransportT]):
586
+ """
587
+ A proxy client that provides a stateful client factory for the proxy server.
588
+
589
+ The stateful proxy client bound its copy to the server session.
590
+ And it will be disconnected when the session is exited.
591
+
592
+ This is useful to proxy a stateful mcp server such as the Playwright MCP server.
593
+ Note that it is essential to ensure that the proxy server itself is also stateful.
594
+ """
595
+
596
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
597
+ """
598
+ The stateful proxy client will be forced disconnected when the session is exited.
599
+ So we do nothing here.
600
+ """
601
+ pass
602
+
603
+ def new_stateful(self) -> Client[ClientTransportT]:
604
+ """
605
+ Create a new stateful proxy client instance with the same configuration.
606
+
607
+ Use this method as the client factory for stateful proxy server.
608
+ """
609
+ session = get_context().session
610
+ proxy_client = session.__dict__.get("_proxy_client", None)
611
+
612
+ if proxy_client is None:
613
+ proxy_client = self.new()
614
+ logger.debug(f"{proxy_client} created for {session}")
615
+ session.__dict__["_proxy_client"] = proxy_client
616
+
617
+ async def _on_session_exit():
618
+ proxy_client: Client = session.__dict__.pop("_proxy_client")
619
+ logger.debug(f"{proxy_client} will be disconnect")
620
+ await proxy_client._disconnect(force=True)
621
+
622
+ session._exit_stack.push_async_callback(_on_session_exit)
623
+
624
+ return proxy_client