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/cli/cli.py +1 -3
- fastmcp/client/__init__.py +3 -0
- fastmcp/client/auth/__init__.py +4 -0
- fastmcp/client/auth/bearer.py +17 -0
- fastmcp/client/auth/oauth.py +394 -0
- fastmcp/client/client.py +154 -38
- fastmcp/client/oauth_callback.py +310 -0
- fastmcp/client/transports.py +249 -23
- fastmcp/resources/template.py +1 -1
- fastmcp/server/auth/__init__.py +4 -0
- fastmcp/server/auth/auth.py +45 -0
- fastmcp/server/auth/providers/bearer.py +377 -0
- fastmcp/server/auth/providers/bearer_env.py +62 -0
- fastmcp/server/auth/providers/in_memory.py +330 -0
- fastmcp/server/dependencies.py +27 -6
- fastmcp/server/http.py +38 -66
- fastmcp/server/openapi.py +2 -0
- fastmcp/server/server.py +64 -32
- fastmcp/settings.py +34 -8
- fastmcp/tools/tool.py +26 -5
- fastmcp/tools/tool_manager.py +2 -0
- fastmcp/utilities/http.py +8 -0
- fastmcp/utilities/tests.py +22 -10
- {fastmcp-2.5.1.dist-info → fastmcp-2.6.0.dist-info}/METADATA +9 -8
- {fastmcp-2.5.1.dist-info → fastmcp-2.6.0.dist-info}/RECORD +29 -22
- fastmcp/client/base.py +0 -0
- fastmcp/low_level/README.md +0 -1
- fastmcp/py.typed +0 -0
- /fastmcp/{low_level → server/auth/providers}/__init__.py +0 -0
- {fastmcp-2.5.1.dist-info → fastmcp-2.6.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.5.1.dist-info → fastmcp-2.6.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.5.1.dist-info → fastmcp-2.6.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
)
|