Jeremiah Lowin commited on
Commit
cfb4777
·
1 Parent(s): 09f2b34

Introduce MCP client oauth flow

Browse files
src/fastmcp/client/{base.py → auth/__init__.py} RENAMED
File without changes
src/fastmcp/client/auth/httpx_client.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+ import time
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager, contextmanager
8
+ from contextvars import ContextVar
9
+ from typing import Any
10
+ from urllib.parse import urljoin
11
+
12
+ import anyio
13
+ import httpx
14
+ import mcp.client.sse
15
+ import mcp.client.streamable_http
16
+ import mcp.shared._httpx_utils
17
+ from authlib.integrations.httpx_client import AsyncOAuth2Client
18
+ from starlette.applications import Starlette
19
+ from starlette.responses import PlainTextResponse
20
+ from starlette.routing import Route
21
+ from uvicorn import Config, Server
22
+
23
+ from fastmcp.client.auth.oauth_cache import oauth_cache
24
+ from fastmcp.utilities.logging import get_logger
25
+
26
+ _current_mcp_endpoint: ContextVar[str | None] = ContextVar("mcp_endpoint", default=None)
27
+
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ def create_mcp_http_client(
33
+ headers: dict[str, Any] | None = None,
34
+ timeout: httpx.Timeout | None = None,
35
+ **kwargs: Any,
36
+ ) -> httpx.AsyncClient:
37
+ # re-implements logic from mcp.shared._httpx_utils.create_mcp_http_client, but with **kwargs support
38
+ kwargs.setdefault("follow_redirects", True)
39
+ if timeout is None:
40
+ timeout = httpx.Timeout(30.0)
41
+
42
+ return httpx.AsyncClient(headers=headers, timeout=timeout, **kwargs)
43
+
44
+
45
+ def find_available_port() -> int:
46
+ """Find an available port by letting the OS assign one."""
47
+ with socket.socket() as s:
48
+ s.bind(("127.0.0.1", 0))
49
+ return s.getsockname()[1]
50
+
51
+
52
+ async def _get_redirect(
53
+ port: int, path: str = "/callback", timeout: float = 100.0
54
+ ) -> str:
55
+ """
56
+ Start a temporary server to handle OAuth redirect and get the full redirect URL.
57
+
58
+ Args:
59
+ port: The port to run the server on
60
+ path: The path to listen for redirects on
61
+ timeout: Number of seconds to wait before timing out
62
+
63
+ Returns:
64
+ The full redirect URL from the browser
65
+
66
+ Raises:
67
+ TimeoutError: If no redirect is received within the timeout period
68
+ """
69
+ fut = asyncio.get_running_loop().create_future()
70
+
71
+ async def cb(request):
72
+ if not fut.done():
73
+ fut.set_result(str(request.url)) # full redirect URL
74
+ return PlainTextResponse(
75
+ "✅ FastMCP login complete! You can close this tab now."
76
+ )
77
+
78
+ server = Server(
79
+ Config(
80
+ app=Starlette(routes=[Route(path, cb)]),
81
+ host="127.0.0.1",
82
+ port=port,
83
+ lifespan="off",
84
+ log_level="error",
85
+ )
86
+ )
87
+
88
+ async with anyio.create_task_group() as tg:
89
+ tg.start_soon(server.serve) # background task for server
90
+
91
+ try:
92
+ # Use anyio.fail_after to implement timeout
93
+ with anyio.fail_after(timeout):
94
+ redirect_url = await fut # wait for browser hit or timeout
95
+ return redirect_url
96
+ finally:
97
+ server.should_exit = True # stop the server loop
98
+ tg.cancel_scope.cancel() # tear down immediately
99
+
100
+
101
+ class OAuthBearerAuth(httpx.Auth):
102
+ """Auth handler that adds the OAuth bearer token to requests."""
103
+
104
+ def __init__(self, client: AsyncOAuth2Client) -> None:
105
+ self._client = client
106
+
107
+ async def async_auth_flow(self, request: httpx.Request):
108
+ # Ensure token is loaded in the OAuth client
109
+ if (
110
+ not self._client.token
111
+ or self._client.token.get("expires_at")
112
+ and self._client.token["expires_at"] < time.time()
113
+ ):
114
+ # We'll refresh or reauthorize in create_mcp_oauth_client
115
+ pass
116
+
117
+ if self._client.token:
118
+ request.headers["Authorization"] = (
119
+ f"Bearer {self._client.token['access_token']}"
120
+ )
121
+ yield request
122
+
123
+
124
+ async def discover_oauth_metadata(base_url: str) -> dict[str, Any] | None:
125
+ """
126
+ Discover OAuth metadata from the server according to RFC 8414.
127
+
128
+ Returns None if the server appears to not require authentication.
129
+ """
130
+ # First, try the well-known URL
131
+ well_known_url = urljoin(base_url, "/.well-known/oauth-authorization-server")
132
+ logger.debug(f"Attempting OAuth metadata discovery from: {well_known_url}")
133
+
134
+ async with httpx.AsyncClient() as client:
135
+ # First try the well-known URL
136
+ try:
137
+ response = await client.get(well_known_url, timeout=10)
138
+ if response.status_code == 200:
139
+ logger.debug("Successfully discovered OAuth metadata")
140
+ return response.json()
141
+ except httpx.RequestError as e:
142
+ logger.debug(f"Failed to fetch OAuth metadata: {e}")
143
+
144
+ # If well-known discovery fails, check WWW-Authenticate header
145
+ try:
146
+ response = await client.get(base_url, timeout=10)
147
+
148
+ # If the base URL request succeeds without a 401/403 and has no WWW-Authenticate header,
149
+ # the server likely doesn't require authentication
150
+ if (
151
+ response.status_code < 400
152
+ and "WWW-Authenticate" not in response.headers
153
+ ):
154
+ logger.debug("Server appears to not require authentication")
155
+ return None
156
+
157
+ auth_header = response.headers.get("WWW-Authenticate")
158
+ if auth_header and "resource_metadata" in auth_header:
159
+ # Extract metadata URL from header
160
+ import re
161
+
162
+ metadata_match = re.search(r'resource_metadata="([^"]+)"', auth_header)
163
+ if metadata_match:
164
+ metadata_url = metadata_match.group(1)
165
+ metadata_response = await client.get(metadata_url, timeout=10)
166
+ if metadata_response.status_code == 200:
167
+ logger.debug(
168
+ "Successfully discovered OAuth metadata from WWW-Authenticate header"
169
+ )
170
+ return metadata_response.json()
171
+ except httpx.RequestError as e:
172
+ logger.debug(f"Failed to fetch OAuth metadata from WWW-Authenticate: {e}")
173
+
174
+ # Fallback to default endpoints based on the base URL
175
+ logger.debug("Falling back to default OAuth endpoints")
176
+ return {
177
+ "issuer": base_url,
178
+ "authorization_endpoint": urljoin(base_url, "/authorize"),
179
+ "token_endpoint": urljoin(base_url, "/token"),
180
+ "registration_endpoint": urljoin(base_url, "/register"),
181
+ "response_types_supported": ["code"],
182
+ "response_modes_supported": ["query"],
183
+ "grant_types_supported": ["authorization_code", "refresh_token"],
184
+ "token_endpoint_auth_methods_supported": [
185
+ "client_secret_basic",
186
+ "client_secret_post",
187
+ "none",
188
+ ],
189
+ "code_challenge_methods_supported": ["S256"],
190
+ }
191
+
192
+
193
+ async def register_client(
194
+ registration_endpoint: str, redirect_uri: str
195
+ ) -> dict[str, Any]:
196
+ """
197
+ Register an OAuth client using RFC 7591 dynamic registration.
198
+
199
+ May raise httpx.HTTPStatusError if registration fails.
200
+ """
201
+ logger.debug(f"Registering client at: {registration_endpoint}")
202
+
203
+ payload = {
204
+ "client_name": "FastMCP Client",
205
+ "redirect_uris": [redirect_uri],
206
+ "grant_types": ["authorization_code", "refresh_token"],
207
+ "response_types": ["code"],
208
+ "token_endpoint_auth_method": "none", # public PKCE client
209
+ }
210
+
211
+ async with httpx.AsyncClient() as client:
212
+ response = await client.post(registration_endpoint, json=payload, timeout=10)
213
+ # Allow HTTPStatusError to propagate to the caller
214
+ response.raise_for_status()
215
+ logger.debug("Client registration successful")
216
+ return response.json()
217
+
218
+
219
+ @asynccontextmanager
220
+ async def create_mcp_oauth_client(
221
+ mcp_endpoint: str,
222
+ redirect_uri: str | None = None,
223
+ scope: list[str] | None = None,
224
+ headers: dict[str, Any] | None = None,
225
+ timeout: httpx.Timeout | None = None,
226
+ **httpx_kwargs: Any,
227
+ ) -> AsyncIterator[httpx.AsyncClient]:
228
+ """
229
+ Create an authenticated OAuth client for an MCP server from an endpoint URL.
230
+
231
+ This function handles:
232
+ 1. OAuth metadata discovery
233
+ 2. Dynamic client registration if needed
234
+ 3. Authorization code flow with PKCE
235
+ 4. Token refreshing
236
+ 5. Token persistence
237
+
238
+ If the server doesn't require authentication, a regular client will be returned.
239
+
240
+ Args:
241
+ mcp_endpoint: Full URL to an MCP endpoint (e.g.,
242
+ https://mcp.example.com/sse). This will be used to discover the OAuth
243
+ configuration.
244
+ redirect_uri: OAuth redirect URI for the authorization flow. If None,
245
+ a server will be started on an available port.
246
+ scope: OAuth scopes to request
247
+ headers: Additional headers to include in the requests
248
+ timeout: Timeout for the requests
249
+ **httpx_kwargs: Additional arguments for the httpx client
250
+
251
+ Returns:
252
+ An httpx.AsyncClient that handles authentication automatically
253
+ """
254
+ # Extract base URL for OAuth discovery
255
+ base_url = oauth_cache.get_base_url(mcp_endpoint)
256
+ logger.debug(f"MCP Endpoint: {mcp_endpoint}")
257
+ logger.debug(f"Base URL for OAuth: {base_url}")
258
+
259
+ # Discover OAuth metadata
260
+ metadata = await discover_oauth_metadata(base_url)
261
+
262
+ # If metadata is None, the server doesn't require authentication
263
+ if metadata is None:
264
+ logger.info("Server doesn't require authentication, creating regular client")
265
+ async with create_mcp_http_client(
266
+ headers=headers,
267
+ timeout=timeout,
268
+ **httpx_kwargs,
269
+ ) as client:
270
+ yield client
271
+ return
272
+
273
+ logger.debug(f"Using OAuth endpoints: {metadata}")
274
+
275
+ # Use dynamic redirect URI if none provided
276
+ # Generate port only once and reuse it for all operations
277
+ port = None
278
+ if redirect_uri is None:
279
+ port = find_available_port()
280
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
281
+ logger.debug(f"Using dynamic redirect URI: {redirect_uri}")
282
+
283
+ # Load or register client - check if we need to update registration due to new redirect URI
284
+ creds = oauth_cache.load(mcp_endpoint, "client")
285
+ if creds and redirect_uri not in creds.get("redirect_uris", []):
286
+ logger.debug("Redirect URI not in registered URIs, re-registering client")
287
+ creds = None # Force re-registration
288
+
289
+ # Register if needed
290
+ if not creds:
291
+ try:
292
+ creds = await register_client(
293
+ metadata["registration_endpoint"], redirect_uri
294
+ )
295
+ oauth_cache.save(mcp_endpoint, creds, "client")
296
+ logger.debug(f"Client registered with redirect URI: {redirect_uri}")
297
+ except httpx.HTTPStatusError as e:
298
+ if e.response.status_code == 404:
299
+ # If registration endpoint returns 404, server likely doesn't support OAuth
300
+ logger.info("Registration endpoint not found, creating regular client")
301
+ async with create_mcp_http_client(
302
+ headers=headers,
303
+ timeout=timeout,
304
+ **httpx_kwargs,
305
+ ) as client:
306
+ yield client
307
+ return
308
+ else:
309
+ # Other HTTP errors should be propagated
310
+ raise
311
+
312
+ # Create the OAuth client
313
+ oauth_client = AsyncOAuth2Client(
314
+ client_id=creds["client_id"],
315
+ client_secret=creds.get("client_secret"), # "public" clients omit secret
316
+ scope=scope or ["openid", "profile", "email"],
317
+ redirect_uri=redirect_uri,
318
+ **httpx_kwargs,
319
+ )
320
+
321
+ # Load token if exists - passing mcp_endpoint directly
322
+ token = oauth_cache.load(mcp_endpoint, "token")
323
+ if token:
324
+ oauth_client.token = token
325
+
326
+ try:
327
+ # Ensure we have a valid token
328
+ if (
329
+ not oauth_client.token
330
+ or not oauth_client.token.get("expires_at")
331
+ or oauth_client.token["expires_at"] < time.time()
332
+ ):
333
+ # Try to refresh if possible
334
+ if oauth_client.token and oauth_client.token.get("refresh_token"):
335
+ logger.debug("Refreshing token")
336
+ try:
337
+ # ignore type because refresh_token is awaitable but not typed as such
338
+ token = await oauth_client.refresh_token( # type: ignore[await-expr]
339
+ url=metadata["token_endpoint"],
340
+ refresh_token=oauth_client.token["refresh_token"],
341
+ )
342
+ except Exception as e:
343
+ logger.warning(f"Failed to refresh token: {e}")
344
+ token = None
345
+ else:
346
+ token = None
347
+
348
+ # If token is still not available, start authorization flow
349
+ if not token:
350
+ # Start authorization flow with PKCE
351
+ logger.info("Starting authorization flow")
352
+ uri, _ = oauth_client.create_authorization_url(
353
+ metadata["authorization_endpoint"],
354
+ redirect_uri=redirect_uri,
355
+ code_challenge_method="S256",
356
+ )
357
+ import webbrowser
358
+
359
+ webbrowser.open(uri)
360
+
361
+ # Wait for redirect after user approval - reuse the same port
362
+ try:
363
+ # We need to ensure port is not None for the _get_redirect function
364
+ redirect_port = port if port is not None else find_available_port()
365
+ redirect_url = await _get_redirect(port=redirect_port)
366
+ logger.info("Received redirect, fetching token")
367
+
368
+ # ignore type because fetch_token is awaitable but not typed as such
369
+ token = await oauth_client.fetch_token( # type: ignore[await-expr]
370
+ url=metadata["token_endpoint"],
371
+ authorization_response=redirect_url,
372
+ timeout=15, # seconds
373
+ )
374
+ except Exception as e:
375
+ logger.warning(f"Failed to fetch token: {e}")
376
+ token = None
377
+
378
+ # Save token for future use - passing mcp_endpoint directly
379
+ if token is not None:
380
+ oauth_cache.save(mcp_endpoint, token, "token")
381
+ logger.debug("Token saved successfully")
382
+
383
+ # Create a standard httpx client with the OAuth bearer auth
384
+ async with create_mcp_http_client(
385
+ auth=OAuthBearerAuth(oauth_client),
386
+ headers=headers,
387
+ timeout=timeout,
388
+ **httpx_kwargs,
389
+ ) as client:
390
+ # Yield the authenticated client
391
+ yield client
392
+ finally:
393
+ await oauth_client.aclose()
394
+
395
+
396
+ @contextmanager
397
+ def patch_mcp_httpx_client(mcp_endpoint: str):
398
+ """
399
+ This context manager can be used to monkeypatch the low-level function that
400
+ returns an httpx.AsyncClient. It replaces it with a function that returns an
401
+ MCP OAuth-aware client.
402
+
403
+ This is ugly, but it lets us reuse the low-level SDK without maintaining a fork.
404
+ """
405
+ original_shttp_client_fn = mcp.client.streamable_http.create_mcp_http_client # type: ignore
406
+ original_sse_client_fn = mcp.client.sse.create_mcp_http_client # type: ignore
407
+
408
+ # use tokens to manage context across concurrent requests
409
+ token = _current_mcp_endpoint.set(mcp_endpoint)
410
+
411
+ def patched_mcp_client(**kwargs):
412
+ url = _current_mcp_endpoint.get()
413
+ if url is None:
414
+ return mcp.shared._httpx_utils.create_mcp_http_client(**kwargs)
415
+ return create_mcp_oauth_client(mcp_endpoint=url, **kwargs)
416
+
417
+ try:
418
+ mcp.client.streamable_http.create_mcp_http_client = patched_mcp_client # type: ignore
419
+ mcp.client.sse.create_mcp_http_client = patched_mcp_client # type: ignore
420
+ yield
421
+ finally:
422
+ _current_mcp_endpoint.reset(token)
423
+ mcp.client.streamable_http.create_mcp_http_client = original_shttp_client_fn # type: ignore
424
+ mcp.client.sse.create_mcp_http_client = original_sse_client_fn # type: ignore
src/fastmcp/client/auth/oauth_cache.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Any, ClassVar, Literal
5
+ from urllib.parse import urlparse
6
+
7
+ from fastmcp.client.auth.httpx_client import logger
8
+ from fastmcp.settings import settings
9
+
10
+
11
+ class OAuthCache:
12
+ """Manages OAuth credentials and tokens caching."""
13
+
14
+ # Class variables
15
+ CACHE_DIR: ClassVar[Path] = settings.home / "oauth-cache"
16
+
17
+ def __init__(self):
18
+ """Initialize the cache directory."""
19
+ self.CACHE_DIR.mkdir(exist_ok=True, parents=True)
20
+
21
+ @staticmethod
22
+ def get_base_url(url: str) -> str:
23
+ """Extract the base URL (scheme + host) from a URL with a path."""
24
+ parsed = urlparse(url)
25
+ return f"{parsed.scheme}://{parsed.netloc}"
26
+
27
+ def get_cache_key(self, url: str) -> str:
28
+ """Generate a safe filesystem key from a URL, automatically extracting the base URL."""
29
+ base_url = self.get_base_url(url)
30
+ # Replace scheme:// and non-alphanumeric characters with _ for safety
31
+ return base_url.replace("://", "_").replace(".", "_").replace("/", "_")
32
+
33
+ def get_file_path(self, url: str, file_type: Literal["client", "token"]) -> Path:
34
+ """Get the file path for the specified cache file type and URL."""
35
+ key = self.get_cache_key(url)
36
+ return self.CACHE_DIR / f"{key}_{file_type}.json"
37
+
38
+ def save(
39
+ self, url: str, data: dict[str, Any], file_type: Literal["client", "token"]
40
+ ) -> None:
41
+ """Save data to the cache file using the base URL extracted from url."""
42
+ path = self.get_file_path(url, file_type)
43
+ path.write_text(json.dumps(data))
44
+ base_url = self.get_base_url(url)
45
+ logger.debug(f"Saved {file_type} data for {base_url}")
46
+
47
+ def load(
48
+ self, url: str, file_type: Literal["client", "token"]
49
+ ) -> dict[str, Any] | None:
50
+ """Load data from the cache file using the base URL extracted from url."""
51
+ path = self.get_file_path(url, file_type)
52
+ try:
53
+ return json.loads(path.read_text())
54
+ except (FileNotFoundError, json.JSONDecodeError):
55
+ base_url = self.get_base_url(url)
56
+ logger.debug(f"No valid {file_type} cache found for {base_url}")
57
+ return None
58
+
59
+ def has_valid_token(self, url: str) -> bool:
60
+ """Check if there's a valid non-expired token for the given URL."""
61
+ token = self.load(url, "token")
62
+ if not token:
63
+ return False
64
+
65
+ # Check expiration
66
+ expires_at = token.get("expires_at")
67
+ if not expires_at or expires_at < time.time():
68
+ return False
69
+
70
+ return True
71
+
72
+ def list_cached_endpoints(self) -> list[str]:
73
+ """List all base URLs with cached credentials or tokens."""
74
+ endpoints = set()
75
+
76
+ file_types = ["client", "token"]
77
+ for file_type in file_types:
78
+ for file in self.CACHE_DIR.glob(f"*_{file_type}.json"):
79
+ key = file.name.replace(f"_{file_type}.json", "")
80
+ # This is a simplified conversion back to URL format
81
+ # May need enhancement for complex URLs
82
+ url = key.replace("_", "://", 1)
83
+ # Attempt to reconstruct the URL in a basic way
84
+ parts = url.split("_")
85
+ if len(parts) > 1:
86
+ # Reconstruct with dots and slashes
87
+ reconstructed = parts[0]
88
+ for part in parts[1:]:
89
+ if part:
90
+ reconstructed += f".{part}"
91
+ endpoints.add(reconstructed)
92
+ else:
93
+ endpoints.add(url)
94
+
95
+ return sorted(list(endpoints))
96
+
97
+ def clear(self, url: str | None = None) -> None:
98
+ """
99
+ Clear the OAuth cache for a specific URL or all cached data.
100
+
101
+ Args:
102
+ url: The URL to clear cache for. If None, clears all cache.
103
+ """
104
+ if url is None:
105
+ # Clear all files in the cache directory
106
+ file_types = ["client", "token"]
107
+ for file_type in file_types:
108
+ for file in self.CACHE_DIR.glob(f"*_{file_type}.json"):
109
+ file.unlink(missing_ok=True)
110
+ logger.info("Cleared all OAuth cache data")
111
+ else:
112
+ # Clear only files for the specific URL
113
+ path = self.get_file_path(url, "client")
114
+ path.unlink(missing_ok=True)
115
+ path = self.get_file_path(url, "token")
116
+ path.unlink(missing_ok=True)
117
+ base_url = self.get_base_url(url)
118
+ logger.info(f"Cleared OAuth cache for {base_url}")
119
+
120
+
121
+ # Initialize global cache instance
122
+ oauth_cache = OAuthCache()
src/fastmcp/client/client.py CHANGED
@@ -1,12 +1,22 @@
 
 
1
  import datetime
 
2
  from contextlib import AsyncExitStack
3
  from pathlib import Path
4
- from typing import Any, cast
5
 
6
  import mcp.types
7
  from exceptiongroup import catch
8
  from mcp import ClientSession
 
 
 
 
 
 
9
  from pydantic import AnyUrl
 
10
 
11
  from fastmcp.client.logging import LogHandler, MessageHandler
12
  from fastmcp.client.roots import (
@@ -19,10 +29,10 @@ from fastmcp.exceptions import ToolError
19
  from fastmcp.server import FastMCP
20
  from fastmcp.utilities.exceptions import get_catch_handlers
21
 
22
- from .transports import ClientTransport, SessionKwargs, infer_transport
23
-
24
  __all__ = [
25
  "Client",
 
 
26
  "RootsHandler",
27
  "RootsList",
28
  "LogHandler",
@@ -31,6 +41,51 @@ __all__ = [
31
  ]
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  class Client:
35
  """
36
  MCP client that delegates connection management to a Transport instance.
@@ -76,6 +131,8 @@ class Client:
76
  message_handler: MessageHandler | None = None,
77
  timeout: datetime.timedelta | float | int | None = None,
78
  ):
 
 
79
  self.transport = infer_transport(transport)
80
  self._session: ClientSession | None = None
81
  self._exit_stack: AsyncExitStack | None = None
 
1
+ import abc
2
+ import contextlib
3
  import datetime
4
+ from collections.abc import AsyncIterator
5
  from contextlib import AsyncExitStack
6
  from pathlib import Path
7
+ from typing import Any, TypedDict, cast
8
 
9
  import mcp.types
10
  from exceptiongroup import catch
11
  from mcp import ClientSession
12
+ from mcp.client.session import (
13
+ ListRootsFnT,
14
+ LoggingFnT,
15
+ MessageHandlerFnT,
16
+ SamplingFnT,
17
+ )
18
  from pydantic import AnyUrl
19
+ from typing_extensions import Unpack
20
 
21
  from fastmcp.client.logging import LogHandler, MessageHandler
22
  from fastmcp.client.roots import (
 
29
  from fastmcp.server import FastMCP
30
  from fastmcp.utilities.exceptions import get_catch_handlers
31
 
 
 
32
  __all__ = [
33
  "Client",
34
+ "ClientTransport",
35
+ "SessionKwargs",
36
  "RootsHandler",
37
  "RootsList",
38
  "LogHandler",
 
41
  ]
42
 
43
 
44
+ class SessionKwargs(TypedDict, total=False):
45
+ """Keyword arguments for the MCP ClientSession constructor."""
46
+
47
+ sampling_callback: SamplingFnT | None
48
+ list_roots_callback: ListRootsFnT | None
49
+ logging_callback: LoggingFnT | None
50
+ message_handler: MessageHandlerFnT | None
51
+ read_timeout_seconds: datetime.timedelta | None
52
+
53
+
54
+ class ClientTransport(abc.ABC):
55
+ """
56
+ Abstract base class for different MCP client transport mechanisms.
57
+
58
+ A Transport is responsible for establishing and managing connections
59
+ to an MCP server, and providing a ClientSession within an async context.
60
+ """
61
+
62
+ @abc.abstractmethod
63
+ @contextlib.asynccontextmanager
64
+ async def connect_session(
65
+ self, **session_kwargs: Unpack[SessionKwargs]
66
+ ) -> AsyncIterator[ClientSession]:
67
+ """
68
+ Establishes a connection and yields an active, initialized ClientSession.
69
+
70
+ The session is guaranteed to be valid only within the scope of the
71
+ async context manager. Connection setup and teardown are handled
72
+ within this context.
73
+
74
+ Args:
75
+ **session_kwargs: Keyword arguments to pass to the ClientSession
76
+ constructor (e.g., callbacks, timeouts).
77
+
78
+ Yields:
79
+ An initialized mcp.ClientSession instance.
80
+ """
81
+ raise NotImplementedError
82
+ yield None # type: ignore
83
+
84
+ def __repr__(self) -> str:
85
+ # Basic representation for subclasses
86
+ return f"<{self.__class__.__name__}>"
87
+
88
+
89
  class Client:
90
  """
91
  MCP client that delegates connection management to a Transport instance.
 
131
  message_handler: MessageHandler | None = None,
132
  timeout: datetime.timedelta | float | int | None = None,
133
  ):
134
+ from fastmcp.client.transports import infer_transport
135
+
136
  self.transport = infer_transport(transport)
137
  self._session: ClientSession | None = None
138
  self._exit_stack: AsyncExitStack | None = None
src/fastmcp/client/sse.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import datetime
3
+ import logging
4
+ from collections.abc import AsyncIterator
5
+ from typing import cast
6
+
7
+ from mcp import ClientSession
8
+ from mcp.client.sse import sse_client
9
+ from pydantic import AnyUrl
10
+ from typing_extensions import Unpack
11
+
12
+ from fastmcp.client.auth.httpx_client import patch_mcp_httpx_client
13
+ from fastmcp.client.client import ClientTransport, SessionKwargs
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SSETransport(ClientTransport):
19
+ """Transport implementation that connects to an MCP server via Server-Sent Events."""
20
+
21
+ def __init__(
22
+ self,
23
+ url: str | AnyUrl,
24
+ headers: dict[str, str] | None = None,
25
+ sse_read_timeout: datetime.timedelta | float | int | None = None,
26
+ ):
27
+ if isinstance(url, AnyUrl):
28
+ url = str(url)
29
+ if not isinstance(url, str) or not url.startswith("http"):
30
+ raise ValueError("Invalid HTTP/S URL provided for SSE.")
31
+ self.url = url
32
+ self.headers = headers or {}
33
+
34
+ if isinstance(sse_read_timeout, int | float):
35
+ sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
36
+ self.sse_read_timeout = sse_read_timeout
37
+
38
+ @contextlib.asynccontextmanager
39
+ async def connect_session(
40
+ self, **session_kwargs: Unpack[SessionKwargs]
41
+ ) -> AsyncIterator[ClientSession]:
42
+ client_kwargs = {}
43
+ # sse_read_timeout has a default value set, so we can't pass None without overriding it
44
+ # instead we simply leave the kwarg out if it's not provided
45
+ if self.sse_read_timeout is not None:
46
+ client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
47
+ if session_kwargs.get("read_timeout_seconds", None) is not None:
48
+ read_timeout_seconds = cast(
49
+ datetime.timedelta, session_kwargs.get("read_timeout_seconds")
50
+ )
51
+ client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
52
+
53
+ with patch_mcp_httpx_client(self.url):
54
+ async with sse_client(
55
+ self.url, headers=self.headers, **client_kwargs
56
+ ) as transport:
57
+ read_stream, write_stream = transport
58
+ async with ClientSession(
59
+ read_stream, write_stream, **session_kwargs
60
+ ) as session:
61
+ await session.initialize()
62
+ yield session
63
+
64
+ def __repr__(self) -> str:
65
+ return f"<SSE(url='{self.url}')>"
src/fastmcp/client/streamable_http.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import datetime
3
+ from collections.abc import AsyncIterator
4
+
5
+ from mcp import ClientSession
6
+ from mcp.client.streamable_http import streamablehttp_client
7
+ from pydantic import AnyUrl
8
+ from typing_extensions import Unpack
9
+
10
+ from fastmcp.client.auth.httpx_client import patch_mcp_httpx_client
11
+ from fastmcp.client.client import ClientTransport, SessionKwargs
12
+ from fastmcp.utilities.logging import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class StreamableHttpTransport(ClientTransport):
18
+ """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
19
+
20
+ def __init__(
21
+ self,
22
+ url: str | AnyUrl,
23
+ headers: dict[str, str] | None = None,
24
+ sse_read_timeout: datetime.timedelta | float | int | None = None,
25
+ ):
26
+ if isinstance(url, AnyUrl):
27
+ url = str(url)
28
+ if not isinstance(url, str) or not url.startswith("http"):
29
+ raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
30
+ self.url = url
31
+ self.headers = headers or {}
32
+
33
+ if isinstance(sse_read_timeout, int | float):
34
+ sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
35
+ self.sse_read_timeout = sse_read_timeout
36
+
37
+ @contextlib.asynccontextmanager
38
+ async def connect_session(
39
+ self, **session_kwargs: Unpack[SessionKwargs]
40
+ ) -> AsyncIterator[ClientSession]:
41
+ client_kwargs = {}
42
+ # sse_read_timeout has a default value set, so we can't pass None without overriding it
43
+ # instead we simply leave the kwarg out if it's not provided
44
+ if self.sse_read_timeout is not None:
45
+ client_kwargs["sse_read_timeout"] = self.sse_read_timeout
46
+ if session_kwargs.get("read_timeout_seconds", None) is not None:
47
+ client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
48
+
49
+ with patch_mcp_httpx_client(self.url):
50
+ async with streamablehttp_client(
51
+ self.url, headers=self.headers, **client_kwargs
52
+ ) as transport:
53
+ read_stream, write_stream, _ = transport
54
+ async with ClientSession(
55
+ read_stream, write_stream, **session_kwargs
56
+ ) as session:
57
+ await session.initialize()
58
+ yield session
59
+
60
+ def __repr__(self) -> str:
61
+ return f"<StreamableHttp(url='{self.url}')>"
src/fastmcp/client/transports.py CHANGED
@@ -1,76 +1,38 @@
1
- import abc
2
  import contextlib
3
- import datetime
4
- import inspect
5
  import os
6
  import shutil
7
  import sys
8
- import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
- from typing import Any, TypedDict, cast
12
 
13
  from mcp import ClientSession, StdioServerParameters
14
- from mcp.client.session import (
15
- ListRootsFnT,
16
- LoggingFnT,
17
- MessageHandlerFnT,
18
- SamplingFnT,
19
- )
20
- from mcp.client.sse import sse_client
21
  from mcp.client.stdio import stdio_client
22
- from mcp.client.streamable_http import streamablehttp_client
23
  from mcp.client.websocket import websocket_client
24
  from mcp.shared.memory import create_connected_server_and_client_session
25
  from pydantic import AnyUrl
26
  from typing_extensions import Unpack
27
 
 
 
 
28
  from fastmcp.server import FastMCP as FastMCPServer
29
 
30
-
31
- class SessionKwargs(TypedDict, total=False):
32
- """Keyword arguments for the MCP ClientSession constructor."""
33
-
34
- sampling_callback: SamplingFnT | None
35
- list_roots_callback: ListRootsFnT | None
36
- logging_callback: LoggingFnT | None
37
- message_handler: MessageHandlerFnT | None
38
- read_timeout_seconds: datetime.timedelta | None
39
-
40
-
41
- class ClientTransport(abc.ABC):
42
- """
43
- Abstract base class for different MCP client transport mechanisms.
44
-
45
- A Transport is responsible for establishing and managing connections
46
- to an MCP server, and providing a ClientSession within an async context.
47
- """
48
-
49
- @abc.abstractmethod
50
- @contextlib.asynccontextmanager
51
- async def connect_session(
52
- self, **session_kwargs: Unpack[SessionKwargs]
53
- ) -> AsyncIterator[ClientSession]:
54
- """
55
- Establishes a connection and yields an active, initialized ClientSession.
56
-
57
- The session is guaranteed to be valid only within the scope of the
58
- async context manager. Connection setup and teardown are handled
59
- within this context.
60
-
61
- Args:
62
- **session_kwargs: Keyword arguments to pass to the ClientSession
63
- constructor (e.g., callbacks, timeouts).
64
-
65
- Yields:
66
- An initialized mcp.ClientSession instance.
67
- """
68
- raise NotImplementedError
69
- yield None # type: ignore
70
-
71
- def __repr__(self) -> str:
72
- # Basic representation for subclasses
73
- return f"<{self.__class__.__name__}>"
74
 
75
 
76
  class WSTransport(ClientTransport):
@@ -99,101 +61,6 @@ class WSTransport(ClientTransport):
99
  return f"<WebSocket(url='{self.url}')>"
100
 
101
 
102
- class SSETransport(ClientTransport):
103
- """Transport implementation that connects to an MCP server via Server-Sent Events."""
104
-
105
- def __init__(
106
- self,
107
- url: str | AnyUrl,
108
- headers: dict[str, str] | None = None,
109
- sse_read_timeout: datetime.timedelta | float | int | None = None,
110
- ):
111
- if isinstance(url, AnyUrl):
112
- url = str(url)
113
- if not isinstance(url, str) or not url.startswith("http"):
114
- raise ValueError("Invalid HTTP/S URL provided for SSE.")
115
- self.url = url
116
- self.headers = headers or {}
117
-
118
- if isinstance(sse_read_timeout, int | float):
119
- sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
120
- self.sse_read_timeout = sse_read_timeout
121
-
122
- @contextlib.asynccontextmanager
123
- async def connect_session(
124
- self, **session_kwargs: Unpack[SessionKwargs]
125
- ) -> AsyncIterator[ClientSession]:
126
- client_kwargs = {}
127
- # sse_read_timeout has a default value set, so we can't pass None without overriding it
128
- # instead we simply leave the kwarg out if it's not provided
129
- if self.sse_read_timeout is not None:
130
- client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
131
- if session_kwargs.get("read_timeout_seconds", None) is not None:
132
- read_timeout_seconds = cast(
133
- datetime.timedelta, session_kwargs.get("read_timeout_seconds")
134
- )
135
- client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
136
-
137
- async with sse_client(
138
- self.url, headers=self.headers, **client_kwargs
139
- ) as transport:
140
- read_stream, write_stream = transport
141
- async with ClientSession(
142
- read_stream, write_stream, **session_kwargs
143
- ) as session:
144
- await session.initialize()
145
- yield session
146
-
147
- def __repr__(self) -> str:
148
- return f"<SSE(url='{self.url}')>"
149
-
150
-
151
- class StreamableHttpTransport(ClientTransport):
152
- """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
153
-
154
- def __init__(
155
- self,
156
- url: str | AnyUrl,
157
- headers: dict[str, str] | None = None,
158
- sse_read_timeout: datetime.timedelta | float | int | None = None,
159
- ):
160
- if isinstance(url, AnyUrl):
161
- url = str(url)
162
- if not isinstance(url, str) or not url.startswith("http"):
163
- raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
164
- self.url = url
165
- self.headers = headers or {}
166
-
167
- if isinstance(sse_read_timeout, int | float):
168
- sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
169
- self.sse_read_timeout = sse_read_timeout
170
-
171
- @contextlib.asynccontextmanager
172
- async def connect_session(
173
- self, **session_kwargs: Unpack[SessionKwargs]
174
- ) -> AsyncIterator[ClientSession]:
175
- client_kwargs = {}
176
- # sse_read_timeout has a default value set, so we can't pass None without overriding it
177
- # instead we simply leave the kwarg out if it's not provided
178
- if self.sse_read_timeout is not None:
179
- client_kwargs["sse_read_timeout"] = self.sse_read_timeout
180
- if session_kwargs.get("read_timeout_seconds", None) is not None:
181
- client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
182
-
183
- async with streamablehttp_client(
184
- self.url, headers=self.headers, **client_kwargs
185
- ) as transport:
186
- read_stream, write_stream, _ = transport
187
- async with ClientSession(
188
- read_stream, write_stream, **session_kwargs
189
- ) as session:
190
- await session.initialize()
191
- yield session
192
-
193
- def __repr__(self) -> str:
194
- return f"<StreamableHttp(url='{self.url}')>"
195
-
196
-
197
  class StdioTransport(ClientTransport):
198
  """
199
  Base transport for connecting to an MCP server via subprocess with stdio.
@@ -500,18 +367,9 @@ def infer_transport(
500
  # the transport is an http(s) URL
501
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
502
  if str(transport).rstrip("/").endswith("/sse"):
503
- warnings.warn(
504
- inspect.cleandoc(
505
- """
506
- As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP.
507
- The provided URL ends in `/sse`, so you may encounter unexpected behavior.
508
- If you intended to use SSE, please use the `SSETransport` class directly.
509
- """
510
- ),
511
- category=UserWarning,
512
- stacklevel=2,
513
- )
514
- return StreamableHttpTransport(url=transport)
515
 
516
  # the transport is a websocket URL
517
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
 
 
1
  import contextlib
 
 
2
  import os
3
  import shutil
4
  import sys
 
5
  from collections.abc import AsyncIterator
6
  from pathlib import Path
7
+ from typing import Any
8
 
9
  from mcp import ClientSession, StdioServerParameters
 
 
 
 
 
 
 
10
  from mcp.client.stdio import stdio_client
 
11
  from mcp.client.websocket import websocket_client
12
  from mcp.shared.memory import create_connected_server_and_client_session
13
  from pydantic import AnyUrl
14
  from typing_extensions import Unpack
15
 
16
+ from fastmcp.client.client import ClientTransport, SessionKwargs
17
+ from fastmcp.client.sse import SSETransport
18
+ from fastmcp.client.streamable_http import StreamableHttpTransport
19
  from fastmcp.server import FastMCP as FastMCPServer
20
 
21
+ __all__ = [
22
+ "ClientTransport",
23
+ "SSETransport",
24
+ "StreamableHttpTransport",
25
+ "FastMCPServer",
26
+ "WSTransport",
27
+ "StdioTransport",
28
+ "PythonStdioTransport",
29
+ "FastMCPStdioTransport",
30
+ "NodeStdioTransport",
31
+ "UvxStdioTransport",
32
+ "NpxStdioTransport",
33
+ "FastMCPTransport",
34
+ "infer_transport",
35
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  class WSTransport(ClientTransport):
 
61
  return f"<WebSocket(url='{self.url}')>"
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  class StdioTransport(ClientTransport):
65
  """
66
  Base transport for connecting to an MCP server via subprocess with stdio.
 
367
  # the transport is an http(s) URL
368
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
369
  if str(transport).rstrip("/").endswith("/sse"):
370
+ return SSETransport(url=transport)
371
+ else:
372
+ return StreamableHttpTransport(url=transport)
 
 
 
 
 
 
 
 
 
373
 
374
  # the transport is a websocket URL
375
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
src/fastmcp/settings.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations as _annotations
2
 
3
  import inspect
 
4
  from typing import TYPE_CHECKING, Annotated, Literal
5
 
6
  from mcp.server.auth.settings import AuthSettings
@@ -27,6 +28,8 @@ class Settings(BaseSettings):
27
  nested_model_default_partial_update=True,
28
  )
29
 
 
 
30
  test_mode: bool = False
31
  log_level: LOG_LEVEL = "INFO"
32
  client_raise_first_exceptiongroup_error: Annotated[
 
1
  from __future__ import annotations as _annotations
2
 
3
  import inspect
4
+ from pathlib import Path
5
  from typing import TYPE_CHECKING, Annotated, Literal
6
 
7
  from mcp.server.auth.settings import AuthSettings
 
28
  nested_model_default_partial_update=True,
29
  )
30
 
31
+ home: Path = Path.home() / ".fastmcp"
32
+
33
  test_mode: bool = False
34
  log_level: LOG_LEVEL = "INFO"
35
  client_raise_first_exceptiongroup_error: Annotated[