fastmcp 2.5.2__py3-none-any.whl → 2.6.1__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.
@@ -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
+ )
@@ -6,10 +6,20 @@ import os
6
6
  import shutil
7
7
  import sys
8
8
  import warnings
9
- from collections.abc import AsyncIterator
9
+ from collections.abc import AsyncIterator, Callable
10
10
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Literal,
15
+ TypedDict,
16
+ TypeVar,
17
+ cast,
18
+ overload,
19
+ )
12
20
 
21
+ import anyio
22
+ import httpx
13
23
  from mcp import ClientSession, StdioServerParameters
14
24
  from mcp.client.session import (
15
25
  ListRootsFnT,
@@ -26,7 +36,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
26
36
  from pydantic import AnyUrl
27
37
  from typing_extensions import Unpack
28
38
 
29
- from fastmcp.server import FastMCP as FastMCPServer
39
+ from fastmcp.client.auth.oauth import OAuth
30
40
  from fastmcp.server.dependencies import get_http_headers
31
41
  from fastmcp.server.server import FastMCP
32
42
  from fastmcp.utilities.logging import get_logger
@@ -40,6 +50,20 @@ logger = get_logger(__name__)
40
50
  # TypeVar for preserving specific ClientTransport subclass types
41
51
  ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
42
52
 
53
+ __all__ = [
54
+ "ClientTransport",
55
+ "SSETransport",
56
+ "StreamableHttpTransport",
57
+ "StdioTransport",
58
+ "PythonStdioTransport",
59
+ "FastMCPStdioTransport",
60
+ "NodeStdioTransport",
61
+ "UvxStdioTransport",
62
+ "NpxStdioTransport",
63
+ "FastMCPTransport",
64
+ "infer_transport",
65
+ ]
66
+
43
67
 
44
68
  class SessionKwargs(TypedDict, total=False):
45
69
  """Keyword arguments for the MCP ClientSession constructor."""
@@ -92,6 +116,10 @@ class ClientTransport(abc.ABC):
92
116
  """Close the transport."""
93
117
  pass
94
118
 
119
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
120
+ if auth is not None:
121
+ raise ValueError("This transport does not support auth")
122
+
95
123
 
96
124
  class WSTransport(ClientTransport):
97
125
  """Transport implementation that connects to an MCP server via WebSockets."""
@@ -131,7 +159,9 @@ class SSETransport(ClientTransport):
131
159
  self,
132
160
  url: str | AnyUrl,
133
161
  headers: dict[str, str] | None = None,
162
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
134
163
  sse_read_timeout: datetime.timedelta | float | int | None = None,
164
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
135
165
  ):
136
166
  if isinstance(url, AnyUrl):
137
167
  url = str(url)
@@ -139,11 +169,21 @@ class SSETransport(ClientTransport):
139
169
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
140
170
  self.url = url
141
171
  self.headers = headers or {}
172
+ self._set_auth(auth)
173
+ self.httpx_client_factory = httpx_client_factory
142
174
 
143
175
  if isinstance(sse_read_timeout, int | float):
144
176
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
145
177
  self.sse_read_timeout = sse_read_timeout
146
178
 
179
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
180
+ if auth == "oauth":
181
+ auth = OAuth(self.url)
182
+ elif isinstance(auth, str):
183
+ self.headers["Authorization"] = auth
184
+ auth = None
185
+ self.auth = auth
186
+
147
187
  @contextlib.asynccontextmanager
148
188
  async def connect_session(
149
189
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -165,7 +205,10 @@ class SSETransport(ClientTransport):
165
205
  )
166
206
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
167
207
 
168
- async with sse_client(self.url, **client_kwargs) as transport:
208
+ if self.httpx_client_factory is not None:
209
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
210
+
211
+ async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport:
169
212
  read_stream, write_stream = transport
170
213
  async with ClientSession(
171
214
  read_stream, write_stream, **session_kwargs
@@ -183,7 +226,9 @@ class StreamableHttpTransport(ClientTransport):
183
226
  self,
184
227
  url: str | AnyUrl,
185
228
  headers: dict[str, str] | None = None,
229
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
186
230
  sse_read_timeout: datetime.timedelta | float | int | None = None,
231
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
187
232
  ):
188
233
  if isinstance(url, AnyUrl):
189
234
  url = str(url)
@@ -191,11 +236,21 @@ class StreamableHttpTransport(ClientTransport):
191
236
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
192
237
  self.url = url
193
238
  self.headers = headers or {}
239
+ self._set_auth(auth)
240
+ self.httpx_client_factory = httpx_client_factory
194
241
 
195
242
  if isinstance(sse_read_timeout, int | float):
196
243
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
197
244
  self.sse_read_timeout = sse_read_timeout
198
245
 
246
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
247
+ if auth == "oauth":
248
+ auth = OAuth(self.url)
249
+ elif isinstance(auth, str):
250
+ self.headers["Authorization"] = auth
251
+ auth = None
252
+ self.auth = auth
253
+
199
254
  @contextlib.asynccontextmanager
200
255
  async def connect_session(
201
256
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -214,7 +269,14 @@ class StreamableHttpTransport(ClientTransport):
214
269
  if session_kwargs.get("read_timeout_seconds", None) is not None:
215
270
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
216
271
 
217
- async with streamablehttp_client(self.url, **client_kwargs) as transport:
272
+ if self.httpx_client_factory is not None:
273
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
274
+
275
+ async with streamablehttp_client(
276
+ self.url,
277
+ auth=self.auth,
278
+ **client_kwargs,
279
+ ) as transport:
218
280
  read_stream, write_stream, _ = transport
219
281
  async with ClientSession(
220
282
  read_stream, write_stream, **session_kwargs
@@ -264,8 +326,8 @@ class StdioTransport(ClientTransport):
264
326
 
265
327
  self._session: ClientSession | None = None
266
328
  self._connect_task: asyncio.Task | None = None
267
- self._ready_event = asyncio.Event()
268
- self._stop_event = asyncio.Event()
329
+ self._ready_event = anyio.Event()
330
+ self._stop_event = anyio.Event()
269
331
 
270
332
  @contextlib.asynccontextmanager
271
333
  async def connect_session(
@@ -328,8 +390,8 @@ class StdioTransport(ClientTransport):
328
390
 
329
391
  # reset variables and events for potential future reconnects
330
392
  self._connect_task = None
331
- self._stop_event = asyncio.Event()
332
- self._ready_event = asyncio.Event()
393
+ self._stop_event = anyio.Event()
394
+ self._ready_event = anyio.Event()
333
395
 
334
396
  async def close(self):
335
397
  await self.disconnect()
@@ -592,7 +654,7 @@ class FastMCPTransport(ClientTransport):
592
654
  tests or scenarios where client and server run in the same runtime.
593
655
  """
594
656
 
595
- def __init__(self, mcp: FastMCPServer | FastMCP1Server):
657
+ def __init__(self, mcp: FastMCP | FastMCP1Server):
596
658
  """Initialize a FastMCPTransport from a FastMCP server instance."""
597
659
 
598
660
  # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
@@ -706,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
706
768
 
707
769
 
708
770
  @overload
709
- def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ...
771
+ def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
710
772
 
711
773
 
712
774
  @overload
@@ -741,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor
741
803
 
742
804
  def infer_transport(
743
805
  transport: ClientTransport
744
- | FastMCPServer
806
+ | FastMCP
745
807
  | FastMCP1Server
746
808
  | AnyUrl
747
809
  | Path
@@ -758,7 +820,7 @@ def infer_transport(
758
820
 
759
821
  The function supports these input types:
760
822
  - ClientTransport: Used directly without modification
761
- - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
823
+ - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
762
824
  - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
763
825
  - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
764
826
  - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@@ -796,7 +858,7 @@ def infer_transport(
796
858
  return transport
797
859
 
798
860
  # the transport is a FastMCP server (2.x or 1.0)
799
- elif isinstance(transport, FastMCPServer | FastMCP1Server):
861
+ elif isinstance(transport, FastMCP | FastMCP1Server):
800
862
  inferred_transport = FastMCPTransport(mcp=transport)
801
863
 
802
864
  # the transport is a path to a script
@@ -0,0 +1,4 @@
1
+ from .providers.bearer import BearerAuthProvider
2
+
3
+
4
+ __all__ = ["BearerAuthProvider"]
@@ -0,0 +1,45 @@
1
+ from mcp.server.auth.provider import (
2
+ AccessToken,
3
+ AuthorizationCode,
4
+ OAuthAuthorizationServerProvider,
5
+ RefreshToken,
6
+ )
7
+ from mcp.server.auth.settings import (
8
+ ClientRegistrationOptions,
9
+ RevocationOptions,
10
+ )
11
+ from pydantic import AnyHttpUrl
12
+
13
+
14
+ class OAuthProvider(
15
+ OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
16
+ ):
17
+ def __init__(
18
+ self,
19
+ issuer_url: AnyHttpUrl | str,
20
+ service_documentation_url: AnyHttpUrl | str | None = None,
21
+ client_registration_options: ClientRegistrationOptions | None = None,
22
+ revocation_options: RevocationOptions | None = None,
23
+ required_scopes: list[str] | None = None,
24
+ ):
25
+ """
26
+ Initialize the OAuth provider.
27
+
28
+ Args:
29
+ issuer_url: The URL of the OAuth issuer.
30
+ service_documentation_url: The URL of the service documentation.
31
+ client_registration_options: The client registration options.
32
+ revocation_options: The revocation options.
33
+ required_scopes: Scopes that are required for all requests.
34
+ """
35
+ super().__init__()
36
+ if isinstance(issuer_url, str):
37
+ issuer_url = AnyHttpUrl(issuer_url)
38
+ if isinstance(service_documentation_url, str):
39
+ service_documentation_url = AnyHttpUrl(service_documentation_url)
40
+
41
+ self.issuer_url = issuer_url
42
+ self.service_documentation_url = service_documentation_url
43
+ self.client_registration_options = client_registration_options
44
+ self.revocation_options = revocation_options
45
+ self.required_scopes = required_scopes