fastmcp 2.5.1__py3-none-any.whl → 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/client/client.py CHANGED
@@ -1,14 +1,17 @@
1
+ import asyncio
1
2
  import datetime
2
3
  from contextlib import AsyncExitStack, asynccontextmanager
3
4
  from pathlib import Path
4
- from typing import Any, cast
5
+ from typing import Any, Generic, Literal, cast, overload
5
6
 
6
7
  import anyio
8
+ import httpx
7
9
  import mcp.types
8
10
  from exceptiongroup import catch
9
11
  from mcp import ClientSession
10
12
  from pydantic import AnyUrl
11
13
 
14
+ import fastmcp
12
15
  from fastmcp.client.logging import (
13
16
  LogHandler,
14
17
  MessageHandler,
@@ -27,10 +30,22 @@ from fastmcp.server import FastMCP
27
30
  from fastmcp.utilities.exceptions import get_catch_handlers
28
31
  from fastmcp.utilities.mcp_config import MCPConfig
29
32
 
30
- from .transports import ClientTransport, SessionKwargs, infer_transport
33
+ from .transports import (
34
+ ClientTransportT,
35
+ FastMCP1Server,
36
+ FastMCPTransport,
37
+ MCPConfigTransport,
38
+ NodeStdioTransport,
39
+ PythonStdioTransport,
40
+ SessionKwargs,
41
+ SSETransport,
42
+ StreamableHttpTransport,
43
+ infer_transport,
44
+ )
31
45
 
32
46
  __all__ = [
33
47
  "Client",
48
+ "SessionKwargs",
34
49
  "RootsHandler",
35
50
  "RootsList",
36
51
  "LogHandler",
@@ -40,13 +55,13 @@ __all__ = [
40
55
  ]
41
56
 
42
57
 
43
- class Client:
58
+ class Client(Generic[ClientTransportT]):
44
59
  """
45
60
  MCP client that delegates connection management to a Transport instance.
46
61
 
47
62
  The Client class is responsible for MCP protocol logic, while the Transport
48
- handles connection establishment and management. Client provides methods
49
- for working with resources, prompts, tools and other MCP capabilities.
63
+ handles connection establishment and management. Client provides methods for
64
+ working with resources, prompts, tools and other MCP capabilities.
50
65
 
51
66
  Args:
52
67
  transport: Connection source specification, which can be:
@@ -62,24 +77,60 @@ class Client:
62
77
  message_handler: Optional handler for protocol messages
63
78
  progress_handler: Optional handler for progress notifications
64
79
  timeout: Optional timeout for requests (seconds or timedelta)
80
+ init_timeout: Optional timeout for initial connection (seconds or timedelta).
81
+ Set to 0 to disable. If None, uses the value in the FastMCP global settings.
65
82
 
66
83
  Examples:
67
- ```python
68
- # Connect to FastMCP server
69
- client = Client("http://localhost:8080")
84
+ ```python # Connect to FastMCP server client =
85
+ Client("http://localhost:8080")
70
86
 
71
87
  async with client:
72
- # List available resources
73
- resources = await client.list_resources()
88
+ # List available resources resources = await client.list_resources()
74
89
 
75
- # Call a tool
76
- result = await client.call_tool("my_tool", {"param": "value"})
90
+ # Call a tool result = await client.call_tool("my_tool", {"param":
91
+ "value"})
77
92
  ```
78
93
  """
79
94
 
95
+ @overload
96
+ def __new__(
97
+ cls,
98
+ transport: ClientTransportT,
99
+ **kwargs: Any,
100
+ ) -> "Client[ClientTransportT]": ...
101
+
102
+ @overload
103
+ def __new__(
104
+ cls, transport: AnyUrl, **kwargs
105
+ ) -> "Client[SSETransport|StreamableHttpTransport]": ...
106
+
107
+ @overload
108
+ def __new__(
109
+ cls, transport: FastMCP | FastMCP1Server, **kwargs
110
+ ) -> "Client[FastMCPTransport]": ...
111
+
112
+ @overload
113
+ def __new__(
114
+ cls, transport: Path, **kwargs
115
+ ) -> "Client[PythonStdioTransport|NodeStdioTransport]": ...
116
+
117
+ @overload
118
+ def __new__(
119
+ cls, transport: MCPConfig | dict[str, Any], **kwargs
120
+ ) -> "Client[MCPConfigTransport]": ...
121
+
122
+ @overload
123
+ def __new__(
124
+ cls, transport: str, **kwargs
125
+ ) -> "Client[PythonStdioTransport|NodeStdioTransport|SSETransport|StreamableHttpTransport]": ...
126
+
127
+ def __new__(cls, transport, **kwargs) -> "Client":
128
+ instance = super().__new__(cls)
129
+ return instance
130
+
80
131
  def __init__(
81
132
  self,
82
- transport: ClientTransport
133
+ transport: ClientTransportT
83
134
  | FastMCP
84
135
  | AnyUrl
85
136
  | Path
@@ -93,11 +144,12 @@ class Client:
93
144
  message_handler: MessageHandler | None = None,
94
145
  progress_handler: ProgressHandler | None = None,
95
146
  timeout: datetime.timedelta | float | int | None = None,
147
+ init_timeout: datetime.timedelta | float | int | None = None,
148
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
96
149
  ):
97
- self.transport = infer_transport(transport)
98
- self._session: ClientSession | None = None
99
- self._exit_stack: AsyncExitStack | None = None
100
- self._nesting_counter: int = 0
150
+ self.transport = cast(ClientTransportT, infer_transport(transport))
151
+ if auth is not None:
152
+ self.transport._set_auth(auth)
101
153
  self._initialize_result: mcp.types.InitializeResult | None = None
102
154
 
103
155
  if log_handler is None:
@@ -111,6 +163,17 @@ class Client:
111
163
  if isinstance(timeout, int | float):
112
164
  timeout = datetime.timedelta(seconds=timeout)
113
165
 
166
+ # handle init handshake timeout
167
+ if init_timeout is None:
168
+ init_timeout = fastmcp.settings.settings.client_init_timeout
169
+ if isinstance(init_timeout, datetime.timedelta):
170
+ init_timeout = init_timeout.total_seconds()
171
+ elif not init_timeout:
172
+ init_timeout = None
173
+ else:
174
+ init_timeout = float(init_timeout)
175
+ self._init_timeout = init_timeout
176
+
114
177
  self._session_kwargs: SessionKwargs = {
115
178
  "sampling_callback": None,
116
179
  "list_roots_callback": None,
@@ -127,6 +190,15 @@ class Client:
127
190
  sampling_handler
128
191
  )
129
192
 
193
+ # session context management
194
+ self._session: ClientSession | None = None
195
+ self._exit_stack: AsyncExitStack | None = None
196
+ self._nesting_counter: int = 0
197
+ self._context_lock = anyio.Lock()
198
+ self._session_task: asyncio.Task | None = None
199
+ self._ready_event = anyio.Event()
200
+ self._stop_event = anyio.Event()
201
+
130
202
  @property
131
203
  def session(self) -> ClientSession:
132
204
  """Get the current active session. Raises RuntimeError if not connected."""
@@ -134,6 +206,7 @@ class Client:
134
206
  raise RuntimeError(
135
207
  "Client is not connected. Use the 'async with client:' context manager first."
136
208
  )
209
+
137
210
  return self._session
138
211
 
139
212
  @property
@@ -168,40 +241,83 @@ class Client:
168
241
  self._session = session
169
242
  # Initialize the session
170
243
  try:
171
- with anyio.fail_after(1):
244
+ with anyio.fail_after(self._init_timeout):
172
245
  self._initialize_result = await self._session.initialize()
173
246
  yield
247
+ except anyio.ClosedResourceError:
248
+ raise RuntimeError("Server session was closed unexpectedly")
174
249
  except TimeoutError:
175
250
  raise RuntimeError("Failed to initialize server session")
176
251
  finally:
177
- self._exit_stack = None
178
252
  self._session = None
179
253
  self._initialize_result = None
180
254
 
181
255
  async def __aenter__(self):
182
- if self._nesting_counter == 0:
183
- # Create exit stack to manage both context managers
184
- stack = AsyncExitStack()
185
- await stack.__aenter__()
186
-
187
- await stack.enter_async_context(self._context_manager())
188
-
189
- self._exit_stack = stack
190
-
191
- self._nesting_counter += 1
192
-
256
+ await self._connect()
193
257
  return self
194
258
 
195
259
  async def __aexit__(self, exc_type, exc_val, exc_tb):
196
- self._nesting_counter -= 1
260
+ await self._disconnect()
261
+
262
+ async def _connect(self):
263
+ # ensure only one session is running at a time to avoid race conditions
264
+ async with self._context_lock:
265
+ need_to_start = self._session_task is None or self._session_task.done()
266
+ if need_to_start:
267
+ self._stop_event = anyio.Event()
268
+ self._ready_event = anyio.Event()
269
+ self._session_task = asyncio.create_task(self._session_runner())
270
+ await self._ready_event.wait()
271
+ self._nesting_counter += 1
272
+ return self
197
273
 
198
- if self._nesting_counter == 0:
199
- # Exit the stack which will handle cleaning up the session
200
- if self._exit_stack is not None:
201
- try:
202
- await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
203
- finally:
204
- self._exit_stack = None
274
+ async def _disconnect(self, force: bool = False):
275
+ # ensure only one session is running at a time to avoid race conditions
276
+ async with self._context_lock:
277
+ # if we are forcing a disconnect, reset the nesting counter
278
+ if force:
279
+ self._nesting_counter = 0
280
+
281
+ # otherwise decrement to check if we are done nesting
282
+ else:
283
+ self._nesting_counter = max(0, self._nesting_counter - 1)
284
+
285
+ # if we are still nested, return
286
+ if self._nesting_counter > 0:
287
+ return
288
+
289
+ # stop the active seesion
290
+ if self._session_task is None:
291
+ return
292
+ self._stop_event.set()
293
+ runner_task = self._session_task
294
+ self._session_task = None
295
+
296
+ # wait for the session to finish
297
+ if runner_task:
298
+ await runner_task
299
+
300
+ # Reset for future reconnects
301
+ self._stop_event = anyio.Event()
302
+ self._ready_event = anyio.Event()
303
+ self._session = None
304
+ self._initialize_result = None
305
+
306
+ async def _session_runner(self):
307
+ async with AsyncExitStack() as stack:
308
+ try:
309
+ await stack.enter_async_context(self._context_manager())
310
+ # Session/context is now ready
311
+ self._ready_event.set()
312
+ # Wait until disconnect/stop is requested
313
+ await self._stop_event.wait()
314
+ finally:
315
+ # On exit, ensure ready event is set (idempotent)
316
+ self._ready_event.set()
317
+
318
+ async def close(self):
319
+ await self._disconnect(force=True)
320
+ await self.transport.close()
205
321
 
206
322
  # --- MCP Client Methods ---
207
323
 
@@ -0,0 +1,310 @@
1
+ """
2
+ OAuth callback server for handling authorization code flows.
3
+
4
+ This module provides a reusable callback server that can handle OAuth redirects
5
+ and display styled responses to users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+
13
+ from starlette.applications import Starlette
14
+ from starlette.requests import Request
15
+ from starlette.responses import HTMLResponse
16
+ from starlette.routing import Route
17
+ from uvicorn import Config, Server
18
+
19
+ from fastmcp.utilities.http import find_available_port
20
+ from fastmcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def create_callback_html(
26
+ message: str,
27
+ is_success: bool = True,
28
+ title: str = "FastMCP OAuth",
29
+ server_url: str | None = None,
30
+ ) -> str:
31
+ """Create a styled HTML response for OAuth callbacks."""
32
+ status_emoji = "✅" if is_success else "❌"
33
+ status_color = "#10b981" if is_success else "#ef4444" # emerald-500 / red-500
34
+
35
+ # Add server info for success cases
36
+ server_info = ""
37
+ if is_success and server_url:
38
+ server_info = f"""
39
+ <div class="server-info">
40
+ Connected to: <strong>{server_url}</strong>
41
+ </div>
42
+ """
43
+
44
+ return f"""
45
+ <!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
50
+ <title>{title}</title>
51
+ <style>
52
+ body {{
53
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
54
+ margin: 0;
55
+ padding: 0;
56
+ min-height: 100vh;
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 25%, #16213e 50%, #0f0f23 100%);
61
+ color: #e2e8f0;
62
+ overflow: hidden;
63
+ }}
64
+
65
+ body::before {{
66
+ content: '';
67
+ position: fixed;
68
+ top: 0;
69
+ left: 0;
70
+ width: 100%;
71
+ height: 100%;
72
+ background:
73
+ radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
74
+ radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
75
+ radial-gradient(circle at 40% 40%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
76
+ pointer-events: none;
77
+ z-index: -1;
78
+ }}
79
+
80
+ .container {{
81
+ background: rgba(30, 41, 59, 0.9);
82
+ backdrop-filter: blur(10px);
83
+ border: 1px solid rgba(71, 85, 105, 0.3);
84
+ padding: 3rem 2rem;
85
+ border-radius: 1rem;
86
+ box-shadow:
87
+ 0 25px 50px -12px rgba(0, 0, 0, 0.7),
88
+ 0 0 0 1px rgba(255, 255, 255, 0.05),
89
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
90
+ text-align: center;
91
+ max-width: 500px;
92
+ margin: 1rem;
93
+ position: relative;
94
+ }}
95
+
96
+ .container::before {{
97
+ content: '';
98
+ position: absolute;
99
+ top: 0;
100
+ left: 0;
101
+ right: 0;
102
+ height: 1px;
103
+ background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.5), transparent);
104
+ }}
105
+
106
+ .status-icon {{
107
+ font-size: 4rem;
108
+ margin-bottom: 1rem;
109
+ display: block;
110
+ filter: drop-shadow(0 0 20px currentColor);
111
+ }}
112
+
113
+ .message {{
114
+ font-size: 1.25rem;
115
+ line-height: 1.6;
116
+ color: {status_color};
117
+ margin-bottom: 1.5rem;
118
+ font-weight: 600;
119
+ text-shadow: 0 0 10px rgba({
120
+ "16, 185, 129" if is_success else "239, 68, 68"
121
+ }, 0.3);
122
+ }}
123
+
124
+ .server-info {{
125
+ background: rgba(6, 182, 212, 0.1);
126
+ border: 1px solid rgba(6, 182, 212, 0.3);
127
+ border-radius: 0.75rem;
128
+ padding: 1rem;
129
+ margin: 1rem 0;
130
+ font-size: 0.9rem;
131
+ color: #67e8f9;
132
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
133
+ text-shadow: 0 0 10px rgba(103, 232, 249, 0.3);
134
+ }}
135
+
136
+ .server-info strong {{
137
+ color: #22d3ee;
138
+ font-weight: 700;
139
+ }}
140
+
141
+ .subtitle {{
142
+ font-size: 1rem;
143
+ color: #94a3b8;
144
+ margin-top: 1rem;
145
+ }}
146
+
147
+ .close-instruction {{
148
+ background: rgba(51, 65, 85, 0.8);
149
+ border: 1px solid rgba(71, 85, 105, 0.4);
150
+ border-radius: 0.75rem;
151
+ padding: 1rem;
152
+ margin-top: 1.5rem;
153
+ font-size: 0.9rem;
154
+ color: #cbd5e1;
155
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
156
+ }}
157
+
158
+ @keyframes glow {{
159
+ 0%, 100% {{ opacity: 1; }}
160
+ 50% {{ opacity: 0.7; }}
161
+ }}
162
+
163
+ .status-icon {{
164
+ animation: glow 2s ease-in-out infinite;
165
+ }}
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div class="container">
170
+ <span class="status-icon">{status_emoji}</span>
171
+ <div class="message">{message}</div>
172
+ {server_info}
173
+ <div class="close-instruction">
174
+ You can safely close this tab now.
175
+ </div>
176
+ </div>
177
+ </body>
178
+ </html>
179
+ """
180
+
181
+
182
+ @dataclass
183
+ class CallbackResponse:
184
+ code: str | None = None
185
+ state: str | None = None
186
+ error: str | None = None
187
+ error_description: str | None = None
188
+
189
+ @classmethod
190
+ def from_dict(cls, data: dict[str, str]) -> CallbackResponse:
191
+ return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
192
+
193
+ def to_dict(self) -> dict[str, str]:
194
+ return {k: v for k, v in self.__dict__.items() if v is not None}
195
+
196
+
197
+ def create_oauth_callback_server(
198
+ port: int,
199
+ callback_path: str = "/callback",
200
+ server_url: str | None = None,
201
+ response_future: asyncio.Future | None = None,
202
+ ) -> Server:
203
+ """
204
+ Create an OAuth callback server.
205
+
206
+ Args:
207
+ port: The port to run the server on
208
+ callback_path: The path to listen for OAuth redirects on
209
+ server_url: Optional server URL to display in success messages
210
+ response_future: Optional future to resolve when OAuth callback is received
211
+
212
+ Returns:
213
+ Configured uvicorn Server instance (not yet running)
214
+ """
215
+
216
+ async def callback_handler(request: Request):
217
+ """Handle OAuth callback requests with proper HTML responses."""
218
+ query_params = dict(request.query_params)
219
+ callback_response = CallbackResponse.from_dict(query_params)
220
+
221
+ if callback_response.error:
222
+ error_desc = callback_response.error_description or "Unknown error"
223
+
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
+ )
231
+
232
+ return HTMLResponse(
233
+ create_callback_html(
234
+ f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
235
+ is_success=False,
236
+ ),
237
+ status_code=400,
238
+ )
239
+
240
+ 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
+ )
246
+
247
+ return HTMLResponse(
248
+ create_callback_html(
249
+ "FastMCP OAuth Error: No authorization code received",
250
+ is_success=False,
251
+ ),
252
+ status_code=400,
253
+ )
254
+
255
+ # Success case
256
+ if response_future and not response_future.done():
257
+ response_future.set_result(
258
+ (callback_response.code, callback_response.state)
259
+ )
260
+
261
+ return HTMLResponse(
262
+ create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
263
+ )
264
+
265
+ app = Starlette(routes=[Route(callback_path, callback_handler)])
266
+
267
+ return Server(
268
+ Config(
269
+ app=app,
270
+ host="127.0.0.1",
271
+ port=port,
272
+ lifespan="off",
273
+ log_level="warning",
274
+ )
275
+ )
276
+
277
+
278
+ if __name__ == "__main__":
279
+ """Run a test server when executed directly."""
280
+ import webbrowser
281
+
282
+ import uvicorn
283
+
284
+ port = find_available_port()
285
+ print("🎭 OAuth Callback Test Server")
286
+ print("📍 Test URLs:")
287
+ print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz")
288
+ print(
289
+ f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
290
+ )
291
+ print(f" Missing: http://localhost:{port}/callback")
292
+ print("🛑 Press Ctrl+C to stop")
293
+ print()
294
+
295
+ # Create test server without future (just for testing HTML responses)
296
+ server = create_oauth_callback_server(
297
+ port=port, server_url="https://fastmcp-test-server.example.com"
298
+ )
299
+
300
+ # Open browser to success example
301
+ webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")
302
+
303
+ # Run with uvicorn directly
304
+ uvicorn.run(
305
+ server.config.app,
306
+ host="127.0.0.1",
307
+ port=port,
308
+ log_level="warning",
309
+ access_log=False,
310
+ )