fastmcp 2.12.5__py3-none-any.whl → 2.13.2__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 (108) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +11 -11
  3. fastmcp/cli/install/claude_code.py +6 -6
  4. fastmcp/cli/install/claude_desktop.py +3 -3
  5. fastmcp/cli/install/cursor.py +18 -12
  6. fastmcp/cli/install/gemini_cli.py +3 -3
  7. fastmcp/cli/install/mcp_json.py +3 -3
  8. fastmcp/cli/run.py +13 -8
  9. fastmcp/client/__init__.py +9 -9
  10. fastmcp/client/auth/oauth.py +115 -217
  11. fastmcp/client/client.py +105 -39
  12. fastmcp/client/logging.py +18 -14
  13. fastmcp/client/oauth_callback.py +85 -171
  14. fastmcp/client/sampling.py +1 -1
  15. fastmcp/client/transports.py +80 -25
  16. fastmcp/contrib/component_manager/__init__.py +1 -1
  17. fastmcp/contrib/component_manager/component_manager.py +2 -2
  18. fastmcp/contrib/component_manager/component_service.py +6 -6
  19. fastmcp/contrib/mcp_mixin/README.md +32 -1
  20. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  21. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  22. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  23. fastmcp/experimental/server/openapi/__init__.py +5 -8
  24. fastmcp/experimental/server/openapi/components.py +11 -7
  25. fastmcp/experimental/server/openapi/routing.py +2 -2
  26. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  27. fastmcp/experimental/utilities/openapi/director.py +14 -15
  28. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  29. fastmcp/experimental/utilities/openapi/models.py +3 -3
  30. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  31. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  32. fastmcp/mcp_config.py +3 -4
  33. fastmcp/prompts/__init__.py +1 -1
  34. fastmcp/prompts/prompt.py +22 -19
  35. fastmcp/prompts/prompt_manager.py +16 -101
  36. fastmcp/resources/__init__.py +5 -5
  37. fastmcp/resources/resource.py +14 -9
  38. fastmcp/resources/resource_manager.py +9 -168
  39. fastmcp/resources/template.py +107 -17
  40. fastmcp/resources/types.py +30 -24
  41. fastmcp/server/__init__.py +1 -1
  42. fastmcp/server/auth/__init__.py +9 -5
  43. fastmcp/server/auth/auth.py +70 -43
  44. fastmcp/server/auth/handlers/authorize.py +326 -0
  45. fastmcp/server/auth/jwt_issuer.py +236 -0
  46. fastmcp/server/auth/middleware.py +96 -0
  47. fastmcp/server/auth/oauth_proxy.py +1510 -289
  48. fastmcp/server/auth/oidc_proxy.py +84 -20
  49. fastmcp/server/auth/providers/auth0.py +40 -21
  50. fastmcp/server/auth/providers/aws.py +29 -3
  51. fastmcp/server/auth/providers/azure.py +312 -131
  52. fastmcp/server/auth/providers/bearer.py +1 -1
  53. fastmcp/server/auth/providers/debug.py +114 -0
  54. fastmcp/server/auth/providers/descope.py +86 -29
  55. fastmcp/server/auth/providers/discord.py +308 -0
  56. fastmcp/server/auth/providers/github.py +29 -8
  57. fastmcp/server/auth/providers/google.py +48 -9
  58. fastmcp/server/auth/providers/in_memory.py +27 -3
  59. fastmcp/server/auth/providers/introspection.py +281 -0
  60. fastmcp/server/auth/providers/jwt.py +48 -31
  61. fastmcp/server/auth/providers/oci.py +233 -0
  62. fastmcp/server/auth/providers/scalekit.py +238 -0
  63. fastmcp/server/auth/providers/supabase.py +188 -0
  64. fastmcp/server/auth/providers/workos.py +35 -17
  65. fastmcp/server/context.py +177 -51
  66. fastmcp/server/dependencies.py +39 -12
  67. fastmcp/server/elicitation.py +1 -1
  68. fastmcp/server/http.py +56 -17
  69. fastmcp/server/low_level.py +121 -2
  70. fastmcp/server/middleware/__init__.py +1 -1
  71. fastmcp/server/middleware/caching.py +476 -0
  72. fastmcp/server/middleware/error_handling.py +14 -10
  73. fastmcp/server/middleware/logging.py +50 -39
  74. fastmcp/server/middleware/middleware.py +29 -16
  75. fastmcp/server/middleware/rate_limiting.py +3 -3
  76. fastmcp/server/middleware/tool_injection.py +116 -0
  77. fastmcp/server/openapi.py +10 -6
  78. fastmcp/server/proxy.py +22 -11
  79. fastmcp/server/server.py +725 -242
  80. fastmcp/settings.py +24 -10
  81. fastmcp/tools/__init__.py +1 -1
  82. fastmcp/tools/tool.py +70 -23
  83. fastmcp/tools/tool_manager.py +30 -112
  84. fastmcp/tools/tool_transform.py +12 -10
  85. fastmcp/utilities/cli.py +67 -28
  86. fastmcp/utilities/components.py +7 -2
  87. fastmcp/utilities/inspect.py +79 -23
  88. fastmcp/utilities/json_schema.py +4 -4
  89. fastmcp/utilities/json_schema_type.py +4 -4
  90. fastmcp/utilities/logging.py +118 -8
  91. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  92. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  93. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  94. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
  95. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  96. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  97. fastmcp/utilities/openapi.py +11 -11
  98. fastmcp/utilities/tests.py +85 -4
  99. fastmcp/utilities/types.py +78 -16
  100. fastmcp/utilities/ui.py +626 -0
  101. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
  102. fastmcp-2.13.2.dist-info/RECORD +144 -0
  103. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  104. fastmcp/cli/claude.py +0 -135
  105. fastmcp/utilities/storage.py +0 -204
  106. fastmcp-2.12.5.dist-info/RECORD +0 -134
  107. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  108. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -7,17 +7,26 @@ and display styled responses to users.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import asyncio
11
10
  from dataclasses import dataclass
12
11
 
12
+ import anyio
13
13
  from starlette.applications import Starlette
14
14
  from starlette.requests import Request
15
- from starlette.responses import HTMLResponse
16
15
  from starlette.routing import Route
17
16
  from uvicorn import Config, Server
18
17
 
19
18
  from fastmcp.utilities.http import find_available_port
20
19
  from fastmcp.utilities.logging import get_logger
20
+ from fastmcp.utilities.ui import (
21
+ HELPER_TEXT_STYLES,
22
+ INFO_BOX_STYLES,
23
+ STATUS_MESSAGE_STYLES,
24
+ create_info_box,
25
+ create_logo,
26
+ create_page,
27
+ create_secure_html_response,
28
+ create_status_message,
29
+ )
21
30
 
22
31
  logger = get_logger(__name__)
23
32
 
@@ -29,155 +38,43 @@ def create_callback_html(
29
38
  server_url: str | None = None,
30
39
  ) -> str:
31
40
  """Create a styled HTML response for OAuth callbacks."""
32
- logo_url = "https://gofastmcp.com/assets/brand/blue-logo.png"
33
-
34
41
  # Build the main status message
35
- if is_success:
36
- status_title = "Authentication successful"
37
- status_icon = "✓"
38
- icon_bg = "#10b98120"
39
- else:
40
- status_title = "Authentication failed"
41
- status_icon = "✕"
42
- icon_bg = "#ef444420"
42
+ status_title = (
43
+ "Authentication successful" if is_success else "Authentication failed"
44
+ )
43
45
 
44
46
  # Add detail info box for both success and error cases
45
47
  detail_info = ""
46
48
  if is_success and server_url:
47
- detail_info = f"""
48
- <div class="info-box">
49
- Connected to: <strong>{server_url}</strong>
50
- </div>
51
- """
49
+ detail_info = create_info_box(
50
+ f"Connected to: {server_url}", centered=True, monospace=True
51
+ )
52
52
  elif not is_success:
53
- detail_info = f"""
54
- <div class="info-box error">
55
- {message}
56
- </div>
57
- """
58
-
59
- return f"""
60
- <!DOCTYPE html>
61
- <html lang="en">
62
- <head>
63
- <meta charset="UTF-8">
64
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
65
- <title>{title}</title>
66
- <style>
67
- * {{
68
- margin: 0;
69
- padding: 0;
70
- box-sizing: border-box;
71
- }}
72
-
73
- body {{
74
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
75
- margin: 0;
76
- padding: 0;
77
- min-height: 100vh;
78
- display: flex;
79
- align-items: center;
80
- justify-content: center;
81
- background: #ffffff;
82
- color: #0a0a0a;
83
- }}
84
-
85
- .container {{
86
- background: #ffffff;
87
- border: 1px solid #e5e5e5;
88
- padding: 3rem 2rem;
89
- border-radius: 0.75rem;
90
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
91
- text-align: center;
92
- max-width: 28rem;
93
- margin: 1rem;
94
- position: relative;
95
- }}
96
-
97
- .logo {{
98
- width: 60px;
99
- height: auto;
100
- margin-bottom: 2rem;
101
- display: block;
102
- margin-left: auto;
103
- margin-right: auto;
104
- }}
105
-
106
- .status-message {{
107
- display: flex;
108
- align-items: center;
109
- justify-content: center;
110
- gap: 0.75rem;
111
- margin-bottom: 1.5rem;
112
- }}
113
-
114
- .status-icon {{
115
- font-size: 1.5rem;
116
- line-height: 1;
117
- display: inline-flex;
118
- align-items: center;
119
- justify-content: center;
120
- width: 2rem;
121
- height: 2rem;
122
- background: {icon_bg};
123
- border-radius: 0.5rem;
124
- flex-shrink: 0;
125
- }}
126
-
127
- .message {{
128
- font-size: 1.125rem;
129
- line-height: 1.75;
130
- color: #0a0a0a;
131
- font-weight: 600;
132
- text-align: left;
133
- }}
134
-
135
- .info-box {{
136
- background: #f5f5f5;
137
- border: 1px solid #e5e5e5;
138
- border-radius: 0.5rem;
139
- padding: 0.875rem;
140
- margin: 1.25rem 0;
141
- font-size: 0.875rem;
142
- color: #525252;
143
- font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
144
- text-align: left;
145
- }}
146
-
147
- .info-box.error {{
148
- background: #fef2f2;
149
- border-color: #fecaca;
150
- color: #991b1b;
151
- }}
152
-
153
- .info-box strong {{
154
- color: #0a0a0a;
155
- font-weight: 600;
156
- }}
157
-
158
- .close-instruction {{
159
- font-size: 0.875rem;
160
- color: #737373;
161
- margin-top: 1.5rem;
162
- }}
163
- </style>
164
- </head>
165
- <body>
53
+ detail_info = create_info_box(
54
+ message, is_error=True, centered=True, monospace=True
55
+ )
56
+
57
+ # Build the page content
58
+ content = f"""
166
59
  <div class="container">
167
- <img src="{logo_url}" alt="FastMCP" class="logo" />
168
- <div class="status-message">
169
- <span class="status-icon">{status_icon}</span>
170
- <div class="message">{status_title}</div>
171
- </div>
60
+ {create_logo()}
61
+ {create_status_message(status_title, is_success=is_success)}
172
62
  {detail_info}
173
63
  <div class="close-instruction">
174
64
  You can safely close this tab now.
175
65
  </div>
176
66
  </div>
177
- </body>
178
- </html>
179
67
  """
180
68
 
69
+ # Additional styles needed for this page
70
+ additional_styles = STATUS_MESSAGE_STYLES + INFO_BOX_STYLES + HELPER_TEXT_STYLES
71
+
72
+ return create_page(
73
+ content=content,
74
+ title=title,
75
+ additional_styles=additional_styles,
76
+ )
77
+
181
78
 
182
79
  @dataclass
183
80
  class CallbackResponse:
@@ -194,11 +91,21 @@ class CallbackResponse:
194
91
  return {k: v for k, v in self.__dict__.items() if v is not None}
195
92
 
196
93
 
94
+ @dataclass
95
+ class OAuthCallbackResult:
96
+ """Container for OAuth callback results, used with anyio.Event for async coordination."""
97
+
98
+ code: str | None = None
99
+ state: str | None = None
100
+ error: Exception | None = None
101
+
102
+
197
103
  def create_oauth_callback_server(
198
104
  port: int,
199
105
  callback_path: str = "/callback",
200
106
  server_url: str | None = None,
201
- response_future: asyncio.Future | None = None,
107
+ result_container: OAuthCallbackResult | None = None,
108
+ result_ready: anyio.Event | None = None,
202
109
  ) -> Server:
203
110
  """
204
111
  Create an OAuth callback server.
@@ -207,7 +114,8 @@ def create_oauth_callback_server(
207
114
  port: The port to run the server on
208
115
  callback_path: The path to listen for OAuth redirects on
209
116
  server_url: Optional server URL to display in success messages
210
- response_future: Optional future to resolve when OAuth callback is received
117
+ result_container: Optional container to store callback results
118
+ result_ready: Optional event to signal when callback is received
211
119
 
212
120
  Returns:
213
121
  Configured uvicorn Server instance (not yet running)
@@ -221,32 +129,36 @@ def create_oauth_callback_server(
221
129
  if callback_response.error:
222
130
  error_desc = callback_response.error_description or "Unknown error"
223
131
 
224
- # Resolve future with exception if provided
225
- if response_future and not response_future.done():
226
- response_future.set_exception(
227
- RuntimeError(
228
- f"OAuth error: {callback_response.error} - {error_desc}"
229
- )
230
- )
132
+ # Create user-friendly error messages
133
+ if callback_response.error == "access_denied":
134
+ user_message = "Access was denied by the authorization server."
135
+ else:
136
+ user_message = f"Authorization failed: {error_desc}"
137
+
138
+ # Store error and signal completion if result tracking provided
139
+ if result_container is not None and result_ready is not None:
140
+ result_container.error = RuntimeError(user_message)
141
+ result_ready.set()
231
142
 
232
- return HTMLResponse(
143
+ return create_secure_html_response(
233
144
  create_callback_html(
234
- f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
145
+ user_message,
235
146
  is_success=False,
236
147
  ),
237
148
  status_code=400,
238
149
  )
239
150
 
240
151
  if not callback_response.code:
241
- # Resolve future with exception if provided
242
- if response_future and not response_future.done():
243
- response_future.set_exception(
244
- RuntimeError("OAuth callback missing authorization code")
245
- )
152
+ user_message = "No authorization code was received from the server."
246
153
 
247
- return HTMLResponse(
154
+ # Store error and signal completion if result tracking provided
155
+ if result_container is not None and result_ready is not None:
156
+ result_container.error = RuntimeError(user_message)
157
+ result_ready.set()
158
+
159
+ return create_secure_html_response(
248
160
  create_callback_html(
249
- "FastMCP OAuth Error: No authorization code received",
161
+ user_message,
250
162
  is_success=False,
251
163
  ),
252
164
  status_code=400,
@@ -254,29 +166,30 @@ def create_oauth_callback_server(
254
166
 
255
167
  # Check for missing state parameter (indicates OAuth flow issue)
256
168
  if callback_response.state is None:
257
- # Resolve future with exception if provided
258
- if response_future and not response_future.done():
259
- response_future.set_exception(
260
- RuntimeError(
261
- "OAuth server did not return state parameter - authentication failed"
262
- )
263
- )
264
-
265
- return HTMLResponse(
169
+ user_message = (
170
+ "The OAuth server did not return the expected state parameter."
171
+ )
172
+
173
+ # Store error and signal completion if result tracking provided
174
+ if result_container is not None and result_ready is not None:
175
+ result_container.error = RuntimeError(user_message)
176
+ result_ready.set()
177
+
178
+ return create_secure_html_response(
266
179
  create_callback_html(
267
- "FastMCP OAuth Error: Authentication failed<br>The OAuth server did not return the expected state parameter",
180
+ user_message,
268
181
  is_success=False,
269
182
  ),
270
183
  status_code=400,
271
184
  )
272
185
 
273
- # Success case
274
- if response_future and not response_future.done():
275
- response_future.set_result(
276
- (callback_response.code, callback_response.state)
277
- )
186
+ # Success case - store result and signal completion if result tracking provided
187
+ if result_container is not None and result_ready is not None:
188
+ result_container.code = callback_response.code
189
+ result_container.state = callback_response.state
190
+ result_ready.set()
278
191
 
279
- return HTMLResponse(
192
+ return create_secure_html_response(
280
193
  create_callback_html("", is_success=True, server_url=server_url)
281
194
  )
282
195
 
@@ -289,6 +202,7 @@ def create_oauth_callback_server(
289
202
  port=port,
290
203
  lifespan="off",
291
204
  log_level="warning",
205
+ ws="websockets-sansio",
292
206
  )
293
207
  )
294
208
 
@@ -11,7 +11,7 @@ from mcp.types import SamplingMessage
11
11
 
12
12
  from fastmcp.server.sampling.handler import ServerSamplingHandler
13
13
 
14
- __all__ = ["SamplingMessage", "SamplingParams", "SamplingHandler"]
14
+ __all__ = ["SamplingHandler", "SamplingMessage", "SamplingParams"]
15
15
 
16
16
 
17
17
  ClientSamplingHandler: TypeAlias = Callable[
@@ -8,7 +8,7 @@ import sys
8
8
  import warnings
9
9
  from collections.abc import AsyncIterator
10
10
  from pathlib import Path
11
- from typing import Any, Literal, TypeVar, cast, overload
11
+ from typing import Any, Literal, TextIO, TypeVar, cast, overload
12
12
 
13
13
  import anyio
14
14
  import httpx
@@ -46,16 +46,16 @@ ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
46
46
 
47
47
  __all__ = [
48
48
  "ClientTransport",
49
- "SSETransport",
50
- "StreamableHttpTransport",
51
- "StdioTransport",
52
- "PythonStdioTransport",
53
49
  "FastMCPStdioTransport",
50
+ "FastMCPTransport",
54
51
  "NodeStdioTransport",
55
- "UvxStdioTransport",
56
- "UvStdioTransport",
57
52
  "NpxStdioTransport",
58
- "FastMCPTransport",
53
+ "PythonStdioTransport",
54
+ "SSETransport",
55
+ "StdioTransport",
56
+ "StreamableHttpTransport",
57
+ "UvStdioTransport",
58
+ "UvxStdioTransport",
59
59
  "infer_transport",
60
60
  ]
61
61
 
@@ -109,9 +109,8 @@ class ClientTransport(abc.ABC):
109
109
  # Basic representation for subclasses
110
110
  return f"<{self.__class__.__name__}>"
111
111
 
112
- async def close(self):
112
+ async def close(self): # noqa: B027
113
113
  """Close the transport."""
114
- pass
115
114
 
116
115
  def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
117
116
  if auth is not None:
@@ -141,10 +140,10 @@ class WSTransport(ClientTransport):
141
140
  ) -> AsyncIterator[ClientSession]:
142
141
  try:
143
142
  from mcp.client.websocket import websocket_client
144
- except ImportError:
143
+ except ImportError as e:
145
144
  raise ImportError(
146
145
  "The websocket transport is not available. Please install fastmcp[websockets] or install the websockets package manually."
147
- )
146
+ ) from e
148
147
 
149
148
  async with websocket_client(self.url) as transport:
150
149
  read_stream, write_stream = transport
@@ -178,8 +177,8 @@ class SSETransport(ClientTransport):
178
177
 
179
178
  self.url = url
180
179
  self.headers = headers or {}
181
- self._set_auth(auth)
182
180
  self.httpx_client_factory = httpx_client_factory
181
+ self._set_auth(auth)
183
182
 
184
183
  if isinstance(sse_read_timeout, int | float):
185
184
  sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
@@ -187,7 +186,7 @@ class SSETransport(ClientTransport):
187
186
 
188
187
  def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
189
188
  if auth == "oauth":
190
- auth = OAuth(self.url)
189
+ auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
191
190
  elif isinstance(auth, str):
192
191
  auth = BearerAuth(auth)
193
192
  self.auth = auth
@@ -207,7 +206,7 @@ class SSETransport(ClientTransport):
207
206
  # instead we simply leave the kwarg out if it's not provided
208
207
  if self.sse_read_timeout is not None:
209
208
  client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
210
- if session_kwargs.get("read_timeout_seconds", None) is not None:
209
+ if session_kwargs.get("read_timeout_seconds") is not None:
211
210
  read_timeout_seconds = cast(
212
211
  datetime.timedelta, session_kwargs.get("read_timeout_seconds")
213
212
  )
@@ -248,8 +247,8 @@ class StreamableHttpTransport(ClientTransport):
248
247
 
249
248
  self.url = url
250
249
  self.headers = headers or {}
251
- self._set_auth(auth)
252
250
  self.httpx_client_factory = httpx_client_factory
251
+ self._set_auth(auth)
253
252
 
254
253
  if isinstance(sse_read_timeout, int | float):
255
254
  sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
@@ -257,7 +256,7 @@ class StreamableHttpTransport(ClientTransport):
257
256
 
258
257
  def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
259
258
  if auth == "oauth":
260
- auth = OAuth(self.url)
259
+ auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
261
260
  elif isinstance(auth, str):
262
261
  auth = BearerAuth(auth)
263
262
  self.auth = auth
@@ -277,7 +276,7 @@ class StreamableHttpTransport(ClientTransport):
277
276
  # instead we simply leave the kwarg out if it's not provided
278
277
  if self.sse_read_timeout is not None:
279
278
  client_kwargs["sse_read_timeout"] = self.sse_read_timeout
280
- if session_kwargs.get("read_timeout_seconds", None) is not None:
279
+ if session_kwargs.get("read_timeout_seconds") is not None:
281
280
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
282
281
 
283
282
  if self.httpx_client_factory is not None:
@@ -313,6 +312,7 @@ class StdioTransport(ClientTransport):
313
312
  env: dict[str, str] | None = None,
314
313
  cwd: str | None = None,
315
314
  keep_alive: bool | None = None,
315
+ log_file: Path | TextIO | None = None,
316
316
  ):
317
317
  """
318
318
  Initialize a Stdio transport.
@@ -326,6 +326,11 @@ class StdioTransport(ClientTransport):
326
326
  Defaults to True. When True, the subprocess remains active
327
327
  after the connection context exits, allowing reuse in
328
328
  subsequent connections.
329
+ log_file: Optional path or file-like object where subprocess stderr will
330
+ be written. Can be a Path or TextIO object. Defaults to sys.stderr
331
+ if not provided. When a Path is provided, the file will be created
332
+ if it doesn't exist, or appended to if it does. When set, server
333
+ errors will be written to this file instead of appearing in the console.
329
334
  """
330
335
  self.command = command
331
336
  self.args = args
@@ -334,6 +339,7 @@ class StdioTransport(ClientTransport):
334
339
  if keep_alive is None:
335
340
  keep_alive = True
336
341
  self.keep_alive = keep_alive
342
+ self.log_file = log_file
337
343
 
338
344
  self._session: ClientSession | None = None
339
345
  self._connect_task: asyncio.Task | None = None
@@ -368,6 +374,7 @@ class StdioTransport(ClientTransport):
368
374
  args=self.args,
369
375
  env=self.env,
370
376
  cwd=self.cwd,
377
+ log_file=self.log_file,
371
378
  session_kwargs=session_kwargs,
372
379
  ready_event=self._ready_event,
373
380
  stop_event=self._stop_event,
@@ -421,6 +428,7 @@ async def _stdio_transport_connect_task(
421
428
  args: list[str],
422
429
  env: dict[str, str] | None,
423
430
  cwd: str | None,
431
+ log_file: Path | TextIO | None,
424
432
  session_kwargs: SessionKwargs,
425
433
  ready_event: anyio.Event,
426
434
  stop_event: anyio.Event,
@@ -438,7 +446,18 @@ async def _stdio_transport_connect_task(
438
446
  env=env,
439
447
  cwd=cwd,
440
448
  )
441
- transport = await stack.enter_async_context(stdio_client(server_params))
449
+ # Handle log_file: Path needs to be opened, TextIO used as-is
450
+ if log_file is None:
451
+ log_file_handle = sys.stderr
452
+ elif isinstance(log_file, Path):
453
+ log_file_handle = stack.enter_context(log_file.open("a"))
454
+ else:
455
+ # Must be TextIO - use it directly
456
+ log_file_handle = log_file
457
+
458
+ transport = await stack.enter_async_context(
459
+ stdio_client(server_params, errlog=log_file_handle)
460
+ )
442
461
  read_stream, write_stream = transport
443
462
  session_future.set_result(
444
463
  await stack.enter_async_context(
@@ -471,6 +490,7 @@ class PythonStdioTransport(StdioTransport):
471
490
  cwd: str | None = None,
472
491
  python_cmd: str = sys.executable,
473
492
  keep_alive: bool | None = None,
493
+ log_file: Path | TextIO | None = None,
474
494
  ):
475
495
  """
476
496
  Initialize a Python transport.
@@ -485,6 +505,11 @@ class PythonStdioTransport(StdioTransport):
485
505
  Defaults to True. When True, the subprocess remains active
486
506
  after the connection context exits, allowing reuse in
487
507
  subsequent connections.
508
+ log_file: Optional path or file-like object where subprocess stderr will
509
+ be written. Can be a Path or TextIO object. Defaults to sys.stderr
510
+ if not provided. When a Path is provided, the file will be created
511
+ if it doesn't exist, or appended to if it does. When set, server
512
+ errors will be written to this file instead of appearing in the console.
488
513
  """
489
514
  script_path = Path(script_path).resolve()
490
515
  if not script_path.is_file():
@@ -502,6 +527,7 @@ class PythonStdioTransport(StdioTransport):
502
527
  env=env,
503
528
  cwd=cwd,
504
529
  keep_alive=keep_alive,
530
+ log_file=log_file,
505
531
  )
506
532
  self.script_path = script_path
507
533
 
@@ -516,6 +542,7 @@ class FastMCPStdioTransport(StdioTransport):
516
542
  env: dict[str, str] | None = None,
517
543
  cwd: str | None = None,
518
544
  keep_alive: bool | None = None,
545
+ log_file: Path | TextIO | None = None,
519
546
  ):
520
547
  script_path = Path(script_path).resolve()
521
548
  if not script_path.is_file():
@@ -529,6 +556,7 @@ class FastMCPStdioTransport(StdioTransport):
529
556
  env=env,
530
557
  cwd=cwd,
531
558
  keep_alive=keep_alive,
559
+ log_file=log_file,
532
560
  )
533
561
  self.script_path = script_path
534
562
 
@@ -544,6 +572,7 @@ class NodeStdioTransport(StdioTransport):
544
572
  cwd: str | None = None,
545
573
  node_cmd: str = "node",
546
574
  keep_alive: bool | None = None,
575
+ log_file: Path | TextIO | None = None,
547
576
  ):
548
577
  """
549
578
  Initialize a Node transport.
@@ -558,6 +587,11 @@ class NodeStdioTransport(StdioTransport):
558
587
  Defaults to True. When True, the subprocess remains active
559
588
  after the connection context exits, allowing reuse in
560
589
  subsequent connections.
590
+ log_file: Optional path or file-like object where subprocess stderr will
591
+ be written. Can be a Path or TextIO object. Defaults to sys.stderr
592
+ if not provided. When a Path is provided, the file will be created
593
+ if it doesn't exist, or appended to if it does. When set, server
594
+ errors will be written to this file instead of appearing in the console.
561
595
  """
562
596
  script_path = Path(script_path).resolve()
563
597
  if not script_path.is_file():
@@ -570,7 +604,12 @@ class NodeStdioTransport(StdioTransport):
570
604
  full_args.extend(args)
571
605
 
572
606
  super().__init__(
573
- command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive
607
+ command=node_cmd,
608
+ args=full_args,
609
+ env=env,
610
+ cwd=cwd,
611
+ keep_alive=keep_alive,
612
+ log_file=log_file,
574
613
  )
575
614
  self.script_path = script_path
576
615
 
@@ -583,15 +622,15 @@ class UvStdioTransport(StdioTransport):
583
622
  command: str,
584
623
  args: list[str] | None = None,
585
624
  module: bool = False,
586
- project_directory: str | None = None,
625
+ project_directory: Path | None = None,
587
626
  python_version: str | None = None,
588
627
  with_packages: list[str] | None = None,
589
- with_requirements: str | None = None,
628
+ with_requirements: Path | None = None,
590
629
  env_vars: dict[str, str] | None = None,
591
630
  keep_alive: bool | None = None,
592
631
  ):
593
632
  # Basic validation
594
- if project_directory and not Path(project_directory).exists():
633
+ if project_directory and not project_directory.exists():
595
634
  raise NotADirectoryError(
596
635
  f"Project directory not found: {project_directory}"
597
636
  )
@@ -707,6 +746,7 @@ class UvxStdioTransport(StdioTransport):
707
746
  env: dict[str, str] | None = None
708
747
  if env_vars:
709
748
  env = os.environ.copy()
749
+ env.update(env_vars)
710
750
 
711
751
  super().__init__(
712
752
  command="uvx",
@@ -810,7 +850,10 @@ class FastMCPTransport(ClientTransport):
810
850
  server_read, server_write = server_streams
811
851
 
812
852
  # Create a cancel scope for the server task
813
- async with anyio.create_task_group() as tg:
853
+ async with (
854
+ anyio.create_task_group() as tg,
855
+ _enter_server_lifespan(server=self.server),
856
+ ):
814
857
  tg.start_soon(
815
858
  lambda: self.server._mcp_server.run(
816
859
  server_read,
@@ -834,6 +877,18 @@ class FastMCPTransport(ClientTransport):
834
877
  return f"<FastMCPTransport(server='{self.server.name}')>"
835
878
 
836
879
 
880
+ @contextlib.asynccontextmanager
881
+ async def _enter_server_lifespan(
882
+ server: FastMCP | FastMCP1Server,
883
+ ) -> AsyncIterator[None]:
884
+ """Enters the server's lifespan context for FastMCP servers and does nothing for FastMCP 1 servers."""
885
+ if isinstance(server, FastMCP):
886
+ async with server._lifespan_manager():
887
+ yield
888
+ else:
889
+ yield
890
+
891
+
837
892
  class MCPConfigTransport(ClientTransport):
838
893
  """Transport for connecting to one or more MCP servers defined in an MCPConfig.
839
894
 
@@ -897,7 +952,7 @@ class MCPConfigTransport(ClientTransport):
897
952
 
898
953
  # if there's exactly one server, create a client for that server
899
954
  elif len(self.config.mcpServers) == 1:
900
- self.transport = list(self.config.mcpServers.values())[0].to_transport()
955
+ self.transport = next(iter(self.config.mcpServers.values())).to_transport()
901
956
  self._underlying_transports.append(self.transport)
902
957
 
903
958
  # otherwise create a composite client
@@ -1,4 +1,4 @@
1
1
  from .component_manager import set_up_component_manager
2
2
  from .component_service import ComponentService
3
3
 
4
- __all__ = ["set_up_component_manager", "ComponentService"]
4
+ __all__ = ["ComponentService", "set_up_component_manager"]