fastmcp 2.12.1__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.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +56 -36
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +7 -16
- fastmcp/cli/install/claude_desktop.py +4 -12
- fastmcp/cli/install/cursor.py +20 -30
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +4 -12
- fastmcp/cli/run.py +15 -94
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +117 -206
- fastmcp/client/client.py +123 -47
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +81 -26
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +7 -7
- fastmcp/contrib/mcp_mixin/README.md +35 -4
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +16 -10
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +33 -7
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +32 -27
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +28 -20
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +119 -27
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +80 -47
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1556 -265
- fastmcp/server/auth/oidc_proxy.py +412 -0
- fastmcp/server/auth/providers/auth0.py +193 -0
- fastmcp/server/auth/providers/aws.py +263 -0
- fastmcp/server/auth/providers/azure.py +314 -129
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +229 -0
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +31 -6
- fastmcp/server/auth/providers/google.py +50 -7
- fastmcp/server/auth/providers/in_memory.py +27 -3
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +37 -15
- fastmcp/server/context.py +194 -67
- fastmcp/server/dependencies.py +56 -16
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +57 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +158 -116
- fastmcp/server/middleware/middleware.py +30 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +15 -7
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +744 -254
- fastmcp/settings.py +65 -15
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +173 -108
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +13 -11
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +21 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +182 -10
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
- fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +93 -10
- fastmcp/utilities/types.py +87 -21
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -144
- fastmcp-2.12.1.dist-info/RECORD +0 -128
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/oauth_callback.py
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
48
|
-
|
|
49
|
-
|
|
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 =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
225
|
-
if
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
143
|
+
return create_secure_html_response(
|
|
233
144
|
create_callback_html(
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
180
|
+
user_message,
|
|
268
181
|
is_success=False,
|
|
269
182
|
),
|
|
270
183
|
status_code=400,
|
|
271
184
|
)
|
|
272
185
|
|
|
273
|
-
# Success case
|
|
274
|
-
if
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
|
fastmcp/client/sampling.py
CHANGED
|
@@ -11,7 +11,7 @@ from mcp.types import SamplingMessage
|
|
|
11
11
|
|
|
12
12
|
from fastmcp.server.sampling.handler import ServerSamplingHandler
|
|
13
13
|
|
|
14
|
-
__all__ = ["
|
|
14
|
+
__all__ = ["SamplingHandler", "SamplingMessage", "SamplingParams"]
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
ClientSamplingHandler: TypeAlias = Callable[
|
fastmcp/client/transports.py
CHANGED
|
@@ -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
|
-
"
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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,
|
|
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:
|
|
625
|
+
project_directory: Path | None = None,
|
|
587
626
|
python_version: str | None = None,
|
|
588
627
|
with_packages: list[str] | None = None,
|
|
589
|
-
with_requirements:
|
|
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
|
|
633
|
+
if project_directory and not project_directory.exists():
|
|
595
634
|
raise NotADirectoryError(
|
|
596
635
|
f"Project directory not found: {project_directory}"
|
|
597
636
|
)
|
|
@@ -609,7 +648,7 @@ class UvStdioTransport(StdioTransport):
|
|
|
609
648
|
uv_args: list[str] = []
|
|
610
649
|
|
|
611
650
|
# Check if we need any environment setup
|
|
612
|
-
if env_config.
|
|
651
|
+
if env_config._must_run_with_uv():
|
|
613
652
|
# Use the config to build args, but we need to handle the command differently
|
|
614
653
|
# since transport has specific needs
|
|
615
654
|
uv_args = ["run"]
|
|
@@ -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
|
|
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 =
|
|
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
|