fastmcp 2.10.5__py3-none-any.whl → 2.11.0__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.
Files changed (65) hide show
  1. fastmcp/__init__.py +7 -2
  2. fastmcp/cli/cli.py +128 -33
  3. fastmcp/cli/install/__init__.py +2 -2
  4. fastmcp/cli/install/claude_code.py +42 -1
  5. fastmcp/cli/install/claude_desktop.py +42 -1
  6. fastmcp/cli/install/cursor.py +42 -1
  7. fastmcp/cli/install/{mcp_config.py → mcp_json.py} +51 -7
  8. fastmcp/cli/run.py +127 -1
  9. fastmcp/client/__init__.py +2 -0
  10. fastmcp/client/auth/oauth.py +68 -99
  11. fastmcp/client/oauth_callback.py +18 -0
  12. fastmcp/client/transports.py +69 -15
  13. fastmcp/contrib/component_manager/example.py +2 -2
  14. fastmcp/experimental/server/openapi/README.md +266 -0
  15. fastmcp/experimental/server/openapi/__init__.py +38 -0
  16. fastmcp/experimental/server/openapi/components.py +348 -0
  17. fastmcp/experimental/server/openapi/routing.py +132 -0
  18. fastmcp/experimental/server/openapi/server.py +466 -0
  19. fastmcp/experimental/utilities/openapi/README.md +239 -0
  20. fastmcp/experimental/utilities/openapi/__init__.py +68 -0
  21. fastmcp/experimental/utilities/openapi/director.py +208 -0
  22. fastmcp/experimental/utilities/openapi/formatters.py +355 -0
  23. fastmcp/experimental/utilities/openapi/json_schema_converter.py +340 -0
  24. fastmcp/experimental/utilities/openapi/models.py +85 -0
  25. fastmcp/experimental/utilities/openapi/parser.py +618 -0
  26. fastmcp/experimental/utilities/openapi/schemas.py +538 -0
  27. fastmcp/mcp_config.py +125 -88
  28. fastmcp/prompts/prompt.py +11 -1
  29. fastmcp/prompts/prompt_manager.py +1 -1
  30. fastmcp/resources/resource.py +21 -1
  31. fastmcp/resources/resource_manager.py +2 -2
  32. fastmcp/resources/template.py +20 -1
  33. fastmcp/server/auth/__init__.py +17 -2
  34. fastmcp/server/auth/auth.py +144 -7
  35. fastmcp/server/auth/providers/bearer.py +25 -473
  36. fastmcp/server/auth/providers/in_memory.py +4 -2
  37. fastmcp/server/auth/providers/jwt.py +538 -0
  38. fastmcp/server/auth/providers/workos.py +170 -0
  39. fastmcp/server/auth/registry.py +52 -0
  40. fastmcp/server/context.py +110 -26
  41. fastmcp/server/dependencies.py +9 -2
  42. fastmcp/server/http.py +62 -30
  43. fastmcp/server/middleware/middleware.py +3 -23
  44. fastmcp/server/openapi.py +26 -13
  45. fastmcp/server/proxy.py +89 -8
  46. fastmcp/server/server.py +170 -62
  47. fastmcp/settings.py +83 -18
  48. fastmcp/tools/tool.py +41 -6
  49. fastmcp/tools/tool_manager.py +39 -3
  50. fastmcp/tools/tool_transform.py +122 -6
  51. fastmcp/utilities/components.py +35 -2
  52. fastmcp/utilities/json_schema.py +136 -98
  53. fastmcp/utilities/json_schema_type.py +1 -3
  54. fastmcp/utilities/mcp_config.py +28 -0
  55. fastmcp/utilities/openapi.py +306 -30
  56. fastmcp/utilities/tests.py +54 -6
  57. fastmcp/utilities/types.py +89 -11
  58. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/METADATA +4 -3
  59. fastmcp-2.11.0.dist-info/RECORD +108 -0
  60. fastmcp/server/auth/providers/bearer_env.py +0 -63
  61. fastmcp/utilities/cache.py +0 -26
  62. fastmcp-2.10.5.dist-info/RECORD +0 -93
  63. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/WHEEL +0 -0
  64. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/entry_points.txt +0 -0
  65. {fastmcp-2.10.5.dist-info → fastmcp-2.11.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/http.py CHANGED
@@ -3,18 +3,20 @@ from __future__ import annotations
3
3
  from collections.abc import AsyncGenerator, Callable, Generator
4
4
  from contextlib import asynccontextmanager, contextmanager
5
5
  from contextvars import ContextVar
6
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING, cast
7
7
 
8
8
  from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
9
9
  from mcp.server.auth.middleware.bearer_auth import (
10
10
  BearerAuthBackend,
11
11
  RequireAuthMiddleware,
12
12
  )
13
+ from mcp.server.auth.provider import TokenVerifier as TokenVerifierProtocol
13
14
  from mcp.server.auth.routes import create_auth_routes
14
15
  from mcp.server.lowlevel.server import LifespanResultT
15
16
  from mcp.server.sse import SseServerTransport
16
17
  from mcp.server.streamable_http import EventStore
17
18
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
19
+ from pydantic import AnyHttpUrl
18
20
  from starlette.applications import Starlette
19
21
  from starlette.middleware import Middleware
20
22
  from starlette.middleware.authentication import AuthenticationMiddleware
@@ -23,7 +25,7 @@ from starlette.responses import Response
23
25
  from starlette.routing import BaseRoute, Mount, Route
24
26
  from starlette.types import Lifespan, Receive, Scope, Send
25
27
 
26
- from fastmcp.server.auth.auth import OAuthProvider
28
+ from fastmcp.server.auth.auth import AuthProvider, OAuthProvider, TokenVerifier
27
29
  from fastmcp.utilities.logging import get_logger
28
30
 
29
31
  if TYPE_CHECKING:
@@ -70,39 +72,46 @@ class RequestContextMiddleware:
70
72
 
71
73
 
72
74
  def setup_auth_middleware_and_routes(
73
- auth: OAuthProvider,
74
- ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
75
+ auth: AuthProvider,
76
+ ) -> tuple[list[Middleware], list[Route], list[str]]:
75
77
  """Set up authentication middleware and routes if auth is enabled.
76
78
 
77
79
  Args:
78
- auth: The OAuthProvider authorization server provider
80
+ auth: An AuthProvider for authentication (TokenVerifier or OAuthProvider)
79
81
 
80
82
  Returns:
81
83
  Tuple of (middleware, auth_routes, required_scopes)
82
84
  """
83
- middleware: list[Middleware] = []
84
- auth_routes: list[BaseRoute] = []
85
- required_scopes: list[str] = []
86
-
87
- middleware = [
85
+ middleware: list[Middleware] = [
88
86
  Middleware(
89
87
  AuthenticationMiddleware,
90
- backend=BearerAuthBackend(auth),
88
+ backend=BearerAuthBackend(cast(TokenVerifierProtocol, auth)),
91
89
  ),
92
90
  Middleware(AuthContextMiddleware),
93
91
  ]
94
92
 
95
- required_scopes = auth.required_scopes or []
96
-
97
- auth_routes.extend(
98
- create_auth_routes(
99
- provider=auth,
100
- issuer_url=auth.issuer_url,
101
- service_documentation_url=auth.service_documentation_url,
102
- client_registration_options=auth.client_registration_options,
103
- revocation_options=auth.revocation_options,
93
+ auth_routes: list[Route] = []
94
+ required_scopes: list[str] = auth.required_scopes or []
95
+
96
+ # Check if it's an OAuthProvider (has OAuth server capability)
97
+ if isinstance(auth, OAuthProvider):
98
+ # OAuthProvider: create standard OAuth routes first
99
+ standard_routes = list(
100
+ create_auth_routes(
101
+ provider=auth,
102
+ issuer_url=auth.issuer_url,
103
+ service_documentation_url=auth.service_documentation_url,
104
+ client_registration_options=auth.client_registration_options,
105
+ revocation_options=auth.revocation_options,
106
+ )
104
107
  )
105
- )
108
+
109
+ # Allow provider to customize routes (e.g., for proxy behavior or metadata endpoints)
110
+ auth_routes = auth.customize_auth_routes(standard_routes)
111
+ else:
112
+ # Simple AuthProvider or TokenVerifier: start with empty routes
113
+ # Allow provider to add custom routes (e.g., metadata endpoints)
114
+ auth_routes = auth.customize_auth_routes([])
106
115
 
107
116
  return middleware, auth_routes, required_scopes
108
117
 
@@ -139,7 +148,7 @@ def create_sse_app(
139
148
  server: FastMCP[LifespanResultT],
140
149
  message_path: str,
141
150
  sse_path: str,
142
- auth: OAuthProvider | None = None,
151
+ auth: AuthProvider | None = None,
143
152
  debug: bool = False,
144
153
  routes: list[BaseRoute] | None = None,
145
154
  middleware: list[Middleware] | None = None,
@@ -150,7 +159,7 @@ def create_sse_app(
150
159
  server: The FastMCP server instance
151
160
  message_path: Path for SSE messages
152
161
  sse_path: Path for SSE connections
153
- auth: Optional auth provider
162
+ auth: Optional authentication provider (AuthProvider)
154
163
  debug: Whether to enable debug mode
155
164
  routes: Optional list of custom routes
156
165
  middleware: Optional list of middleware
@@ -175,8 +184,6 @@ def create_sse_app(
175
184
  return Response()
176
185
 
177
186
  # Get auth middleware and routes
178
-
179
- # Add SSE routes with or without auth
180
187
  if auth:
181
188
  auth_middleware, auth_routes, required_scopes = (
182
189
  setup_auth_middleware_and_routes(auth)
@@ -184,18 +191,32 @@ def create_sse_app(
184
191
 
185
192
  server_routes.extend(auth_routes)
186
193
  server_middleware.extend(auth_middleware)
194
+
195
+ # Determine resource_metadata_url for TokenVerifier
196
+ resource_metadata_url = None
197
+ if isinstance(auth, TokenVerifier) and auth.resource_server_url:
198
+ # Add .well-known path for RFC 9728 compliance
199
+ resource_metadata_url = AnyHttpUrl(
200
+ str(auth.resource_server_url).rstrip("/")
201
+ + "/.well-known/oauth-protected-resource"
202
+ )
203
+
187
204
  # Auth is enabled, wrap endpoints with RequireAuthMiddleware
188
205
  server_routes.append(
189
206
  Route(
190
207
  sse_path,
191
- endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
208
+ endpoint=RequireAuthMiddleware(
209
+ handle_sse, required_scopes, resource_metadata_url
210
+ ),
192
211
  methods=["GET"],
193
212
  )
194
213
  )
195
214
  server_routes.append(
196
215
  Mount(
197
216
  message_path,
198
- app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
217
+ app=RequireAuthMiddleware(
218
+ sse.handle_post_message, required_scopes, resource_metadata_url
219
+ ),
199
220
  )
200
221
  )
201
222
  else:
@@ -243,7 +264,7 @@ def create_streamable_http_app(
243
264
  server: FastMCP[LifespanResultT],
244
265
  streamable_http_path: str,
245
266
  event_store: EventStore | None = None,
246
- auth: OAuthProvider | None = None,
267
+ auth: AuthProvider | None = None,
247
268
  json_response: bool = False,
248
269
  stateless_http: bool = False,
249
270
  debug: bool = False,
@@ -256,7 +277,7 @@ def create_streamable_http_app(
256
277
  server: The FastMCP server instance
257
278
  streamable_http_path: Path for StreamableHTTP connections
258
279
  event_store: Optional event store for session management
259
- auth: Optional auth provider
280
+ auth: Optional authentication provider (AuthProvider)
260
281
  json_response: Whether to use JSON response format
261
282
  stateless_http: Whether to use stateless mode (new transport per request)
262
283
  debug: Whether to enable debug mode
@@ -314,11 +335,22 @@ def create_streamable_http_app(
314
335
  server_routes.extend(auth_routes)
315
336
  server_middleware.extend(auth_middleware)
316
337
 
338
+ # Determine resource_metadata_url for TokenVerifier
339
+ resource_metadata_url = None
340
+ if isinstance(auth, TokenVerifier) and auth.resource_server_url:
341
+ # Add .well-known path for RFC 9728 compliance
342
+ resource_metadata_url = AnyHttpUrl(
343
+ str(auth.resource_server_url).rstrip("/")
344
+ + "/.well-known/oauth-protected-resource"
345
+ )
346
+
317
347
  # Auth is enabled, wrap endpoint with RequireAuthMiddleware
318
348
  server_routes.append(
319
349
  Mount(
320
350
  streamable_http_path,
321
- app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
351
+ app=RequireAuthMiddleware(
352
+ handle_streamable_http, required_scopes, resource_metadata_url
353
+ ),
322
354
  )
323
355
  )
324
356
  else:
@@ -20,7 +20,7 @@ import mcp.types as mt
20
20
  from fastmcp.prompts.prompt import Prompt
21
21
  from fastmcp.resources.resource import Resource
22
22
  from fastmcp.resources.template import ResourceTemplate
23
- from fastmcp.tools.tool import Tool
23
+ from fastmcp.tools.tool import Tool, ToolResult
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from fastmcp.server.context import Context
@@ -43,26 +43,6 @@ class CallNext(Protocol[T, R]):
43
43
  def __call__(self, context: MiddlewareContext[T]) -> Awaitable[R]: ...
44
44
 
45
45
 
46
- ServerResultT = TypeVar(
47
- "ServerResultT",
48
- bound=mt.EmptyResult
49
- | mt.InitializeResult
50
- | mt.CompleteResult
51
- | mt.GetPromptResult
52
- | mt.ListPromptsResult
53
- | mt.ListResourcesResult
54
- | mt.ListResourceTemplatesResult
55
- | mt.ReadResourceResult
56
- | mt.CallToolResult
57
- | mt.ListToolsResult,
58
- )
59
-
60
-
61
- @runtime_checkable
62
- class ServerResultProtocol(Protocol[ServerResultT]):
63
- root: ServerResultT
64
-
65
-
66
46
  @dataclass(kw_only=True, frozen=True)
67
47
  class MiddlewareContext(Generic[T]):
68
48
  """
@@ -167,8 +147,8 @@ class Middleware:
167
147
  async def on_call_tool(
168
148
  self,
169
149
  context: MiddlewareContext[mt.CallToolRequestParams],
170
- call_next: CallNext[mt.CallToolRequestParams, mt.CallToolResult],
171
- ) -> mt.CallToolResult:
150
+ call_next: CallNext[mt.CallToolRequestParams, ToolResult],
151
+ ) -> ToolResult:
172
152
  return await call_next(context)
173
153
 
174
154
  async def on_read_resource(
fastmcp/server/openapi.py CHANGED
@@ -344,18 +344,28 @@ class OpenAPITool(Tool):
344
344
  suffixed_name = f"{p.name}__{p.location}"
345
345
  param_value = None
346
346
 
347
+ suffixed_value = arguments.get(suffixed_name)
347
348
  if (
348
349
  suffixed_name in arguments
349
- and arguments.get(suffixed_name) is not None
350
- and arguments.get(suffixed_name) != ""
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
+ )
351
356
  ):
352
357
  param_value = arguments[suffixed_name]
353
- elif (
354
- p.name in arguments
355
- and arguments.get(p.name) is not None
356
- and arguments.get(p.name) != ""
357
- ):
358
- param_value = arguments[p.name]
358
+ else:
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]
359
369
 
360
370
  if param_value is not None:
361
371
  # Handle different parameter styles and types
@@ -367,7 +377,11 @@ class OpenAPITool(Tool):
367
377
  ) # Default explode for query is True
368
378
 
369
379
  # Handle deepObject style for object parameters
370
- if param_style == "deepObject" and isinstance(param_value, dict):
380
+ if (
381
+ param_style == "deepObject"
382
+ and isinstance(param_value, dict)
383
+ and len(param_value) > 0
384
+ ):
371
385
  if param_explode:
372
386
  # deepObject with explode=true: object properties become separate parameters
373
387
  # e.g., target[id]=123&target[type]=user
@@ -386,6 +400,7 @@ class OpenAPITool(Tool):
386
400
  elif (
387
401
  isinstance(param_value, list)
388
402
  and p.schema_.get("type") == "array"
403
+ and len(param_value) > 0
389
404
  ):
390
405
  if param_explode:
391
406
  # When explode=True, we pass the array directly, which HTTPX will serialize
@@ -444,9 +459,7 @@ class OpenAPITool(Tool):
444
459
  params_to_exclude.add(p.name)
445
460
 
446
461
  body_params = {
447
- k: v
448
- for k, v in arguments.items()
449
- if k not in params_to_exclude and k != "context"
462
+ k: v for k, v in arguments.items() if k not in params_to_exclude
450
463
  }
451
464
 
452
465
  if body_params:
@@ -879,7 +892,7 @@ class FastMCPOpenAPI(FastMCP):
879
892
 
880
893
  # Extract output schema from OpenAPI responses
881
894
  output_schema = extract_output_schema_from_responses(
882
- route.responses, route.schema_definitions
895
+ route.responses, route.schema_definitions, route.openapi_version
883
896
  )
884
897
 
885
898
  # Get a unique tool name
fastmcp/server/proxy.py CHANGED
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, cast
7
7
  from urllib.parse import quote
8
8
 
9
9
  import mcp.types
10
+ from mcp import ServerSession
10
11
  from mcp.client.session import ClientSession
11
12
  from mcp.shared.context import LifespanContextT, RequestContext
12
13
  from mcp.shared.exceptions import McpError
@@ -36,6 +37,9 @@ from fastmcp.server.dependencies import get_context
36
37
  from fastmcp.server.server import FastMCP
37
38
  from fastmcp.tools.tool import Tool, ToolResult
38
39
  from fastmcp.tools.tool_manager import ToolManager
40
+ from fastmcp.tools.tool_transform import (
41
+ apply_transformations_to_tools,
42
+ )
39
43
  from fastmcp.utilities.components import MirroredComponent
40
44
  from fastmcp.utilities.logging import get_logger
41
45
 
@@ -71,7 +75,12 @@ class ProxyToolManager(ToolManager):
71
75
  else:
72
76
  raise e
73
77
 
74
- return all_tools
78
+ transformed_tools = apply_transformations_to_tools(
79
+ tools=all_tools,
80
+ transformations=self.transformations,
81
+ )
82
+
83
+ return transformed_tools
75
84
 
76
85
  async def list_tools(self) -> list[Tool]:
77
86
  """Gets the filtered list of tools including local, mounted, and proxy tools."""
@@ -246,6 +255,8 @@ class ProxyTool(Tool, MirroredComponent):
246
255
  parameters=mcp_tool.inputSchema,
247
256
  annotations=mcp_tool.annotations,
248
257
  output_schema=mcp_tool.outputSchema,
258
+ meta=mcp_tool.meta,
259
+ tags=(mcp_tool.meta or {}).get("_fastmcp", {}).get("tags", []),
249
260
  _mirrored=True,
250
261
  )
251
262
 
@@ -294,12 +305,15 @@ class ProxyResource(Resource, MirroredComponent):
294
305
  mcp_resource: mcp.types.Resource,
295
306
  ) -> ProxyResource:
296
307
  """Factory method to create a ProxyResource from a raw MCP resource schema."""
308
+
297
309
  return cls(
298
310
  client=client,
299
311
  uri=mcp_resource.uri,
300
312
  name=mcp_resource.name,
301
313
  description=mcp_resource.description,
302
314
  mime_type=mcp_resource.mimeType or "text/plain",
315
+ meta=mcp_resource.meta,
316
+ tags=(mcp_resource.meta or {}).get("_fastmcp", {}).get("tags", []),
303
317
  _mirrored=True,
304
318
  )
305
319
 
@@ -339,6 +353,8 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
339
353
  description=mcp_template.description,
340
354
  mime_type=mcp_template.mimeType or "text/plain",
341
355
  parameters={}, # Remote templates don't have local parameters
356
+ meta=mcp_template.meta,
357
+ tags=(mcp_template.meta or {}).get("_fastmcp", {}).get("tags", []),
342
358
  _mirrored=True,
343
359
  )
344
360
 
@@ -371,6 +387,8 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
371
387
  name=self.name,
372
388
  description=self.description,
373
389
  mime_type=result[0].mimeType,
390
+ meta=self.meta,
391
+ tags=(self.meta or {}).get("_fastmcp", {}).get("tags", []),
374
392
  _value=value,
375
393
  )
376
394
 
@@ -404,6 +422,8 @@ class ProxyPrompt(Prompt, MirroredComponent):
404
422
  name=mcp_prompt.name,
405
423
  description=mcp_prompt.description,
406
424
  arguments=arguments,
425
+ meta=mcp_prompt.meta,
426
+ tags=(mcp_prompt.meta or {}).get("_fastmcp", {}).get("tags", []),
407
427
  _mirrored=True,
408
428
  )
409
429
 
@@ -469,7 +489,11 @@ class FastMCPProxy(FastMCP):
469
489
  raise ValueError("Must specify 'client_factory'")
470
490
 
471
491
  # Replace the default managers with our specialized proxy managers.
472
- self._tool_manager = ProxyToolManager(client_factory=self.client_factory)
492
+ self._tool_manager = ProxyToolManager(
493
+ client_factory=self.client_factory,
494
+ # Propagate the transformations from the base class tool manager
495
+ transformations=self._tool_manager.transformations,
496
+ )
473
497
  self._resource_manager = ProxyResourceManager(
474
498
  client_factory=self.client_factory
475
499
  )
@@ -554,11 +578,12 @@ class ProxyClient(Client[ClientTransportT]):
554
578
  A handler that forwards the elicitation request from the remote server to the proxy's connected clients and relays the response back to the remote server.
555
579
  """
556
580
  ctx = get_context()
557
- result = await ctx.elicit(message, response_type)
558
- if result.action == "accept":
559
- return result.data
560
- else:
561
- return ElicitResult(action=result.action)
581
+ result = await ctx.session.elicit(
582
+ message=message,
583
+ requestedSchema=params.requestedSchema,
584
+ related_request_id=ctx.request_id,
585
+ )
586
+ return ElicitResult(action=result.action, content=result.content)
562
587
 
563
588
  @classmethod
564
589
  async def default_log_handler(cls, message: LogMessage) -> None:
@@ -566,7 +591,9 @@ class ProxyClient(Client[ClientTransportT]):
566
591
  A handler that forwards the log notification from the remote server to the proxy's connected clients.
567
592
  """
568
593
  ctx = get_context()
569
- await ctx.log(message.data, level=message.level, logger_name=message.logger)
594
+ msg = message.data.get("msg")
595
+ extra = message.data.get("extra")
596
+ await ctx.log(msg, level=message.level, logger_name=message.logger, extra=extra)
570
597
 
571
598
  @classmethod
572
599
  async def default_progress_handler(
@@ -580,3 +607,57 @@ class ProxyClient(Client[ClientTransportT]):
580
607
  """
581
608
  ctx = get_context()
582
609
  await ctx.report_progress(progress, total, message)
610
+
611
+
612
+ class StatefulProxyClient(ProxyClient[ClientTransportT]):
613
+ """
614
+ A proxy client that provides a stateful client factory for the proxy server.
615
+
616
+ The stateful proxy client bound its copy to the server session.
617
+ And it will be disconnected when the session is exited.
618
+
619
+ This is useful to proxy a stateful mcp server such as the Playwright MCP server.
620
+ Note that it is essential to ensure that the proxy server itself is also stateful.
621
+ """
622
+
623
+ def __init__(self, *args, **kwargs):
624
+ super().__init__(*args, **kwargs)
625
+ self._caches: dict[ServerSession, Client[ClientTransportT]] = {}
626
+
627
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
628
+ """
629
+ The stateful proxy client will be forced disconnected when the session is exited.
630
+ So we do nothing here.
631
+ """
632
+ pass
633
+
634
+ async def clear(self):
635
+ """
636
+ Clear all cached clients and force disconnect them.
637
+ """
638
+ while self._caches:
639
+ _, cache = self._caches.popitem()
640
+ await cache._disconnect(force=True)
641
+
642
+ def new_stateful(self) -> Client[ClientTransportT]:
643
+ """
644
+ Create a new stateful proxy client instance with the same configuration.
645
+
646
+ Use this method as the client factory for stateful proxy server.
647
+ """
648
+ session = get_context().session
649
+ proxy_client = self._caches.get(session, None)
650
+
651
+ if proxy_client is None:
652
+ proxy_client = self.new()
653
+ logger.debug(f"{proxy_client} created for {session}")
654
+ self._caches[session] = proxy_client
655
+
656
+ async def _on_session_exit():
657
+ self._caches.pop(session)
658
+ logger.debug(f"{proxy_client} will be disconnect")
659
+ await proxy_client._disconnect(force=True)
660
+
661
+ session._exit_stack.push_async_callback(_on_session_exit)
662
+
663
+ return proxy_client