fastmcp 2.14.1__py3-none-any.whl → 2.14.3__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 +9 -0
- fastmcp/client/auth/oauth.py +4 -1
- fastmcp/client/transports.py +19 -13
- fastmcp/prompts/prompt_manager.py +3 -4
- fastmcp/resources/resource_manager.py +9 -14
- fastmcp/server/auth/auth.py +20 -5
- fastmcp/server/auth/oauth_proxy.py +118 -25
- fastmcp/server/auth/providers/supabase.py +11 -6
- fastmcp/server/context.py +28 -4
- fastmcp/server/dependencies.py +5 -0
- fastmcp/server/elicitation.py +7 -3
- fastmcp/server/middleware/error_handling.py +1 -1
- fastmcp/server/openapi/components.py +2 -4
- fastmcp/server/server.py +24 -57
- fastmcp/server/tasks/handlers.py +19 -10
- fastmcp/server/tasks/protocol.py +12 -8
- fastmcp/server/tasks/subscriptions.py +3 -5
- fastmcp/settings.py +15 -0
- fastmcp/tools/tool.py +5 -1
- fastmcp/tools/tool_manager.py +5 -7
- fastmcp/utilities/cli.py +49 -42
- fastmcp/utilities/json_schema.py +40 -0
- fastmcp/utilities/openapi/schemas.py +4 -4
- fastmcp/utilities/version_check.py +153 -0
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/METADATA +6 -3
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/RECORD +29 -28
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.1.dist-info → fastmcp-2.14.3.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/elicitation.py
CHANGED
|
@@ -304,15 +304,19 @@ def _dict_to_enum_schema(
|
|
|
304
304
|
multi_select: If True, use anyOf pattern; if False, use oneOf pattern
|
|
305
305
|
|
|
306
306
|
Returns:
|
|
307
|
-
{"
|
|
308
|
-
{"anyOf": [
|
|
307
|
+
{"type": "string", "oneOf": [...]} for single-select
|
|
308
|
+
{"anyOf": [...]} for multi-select (used as array items)
|
|
309
309
|
"""
|
|
310
310
|
pattern_key = "anyOf" if multi_select else "oneOf"
|
|
311
311
|
pattern = []
|
|
312
312
|
for value, metadata in enum_dict.items():
|
|
313
313
|
title = metadata.get("title", value)
|
|
314
314
|
pattern.append({"const": value, "title": title})
|
|
315
|
-
|
|
315
|
+
|
|
316
|
+
result: dict[str, Any] = {pattern_key: pattern}
|
|
317
|
+
if not multi_select:
|
|
318
|
+
result["type"] = "string"
|
|
319
|
+
return result
|
|
316
320
|
|
|
317
321
|
|
|
318
322
|
def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
|
|
@@ -87,7 +87,7 @@ class ErrorHandlingMiddleware(Middleware):
|
|
|
87
87
|
return error
|
|
88
88
|
|
|
89
89
|
# Map common exceptions to appropriate MCP error codes
|
|
90
|
-
error_type = type(error)
|
|
90
|
+
error_type = type(error.__cause__) if error.__cause__ else type(error)
|
|
91
91
|
|
|
92
92
|
if error_type in (ValueError, TypeError):
|
|
93
93
|
return McpError(
|
|
@@ -64,10 +64,8 @@ class OpenAPITool(Tool):
|
|
|
64
64
|
try:
|
|
65
65
|
# Get base URL from client
|
|
66
66
|
base_url = (
|
|
67
|
-
str(self._client.base_url)
|
|
68
|
-
|
|
69
|
-
else "http://localhost"
|
|
70
|
-
)
|
|
67
|
+
str(self._client.base_url) if hasattr(self._client, "base_url") else ""
|
|
68
|
+
) or "http://localhost"
|
|
71
69
|
|
|
72
70
|
# Get Headers from client
|
|
73
71
|
cli_headers = (
|
fastmcp/server/server.py
CHANGED
|
@@ -105,23 +105,6 @@ if TYPE_CHECKING:
|
|
|
105
105
|
logger = get_logger(__name__)
|
|
106
106
|
|
|
107
107
|
|
|
108
|
-
def _create_named_fn_wrapper(fn: Callable[..., Any], name: str) -> Callable[..., Any]:
|
|
109
|
-
"""Create a wrapper function with a custom __name__ for Docket registration.
|
|
110
|
-
|
|
111
|
-
Docket uses fn.__name__ as the key for function registration and lookup.
|
|
112
|
-
When mounting servers, we need unique names to avoid collisions between
|
|
113
|
-
mounted servers that have identically-named functions.
|
|
114
|
-
"""
|
|
115
|
-
import functools
|
|
116
|
-
|
|
117
|
-
@functools.wraps(fn)
|
|
118
|
-
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
119
|
-
return await fn(*args, **kwargs)
|
|
120
|
-
|
|
121
|
-
wrapper.__name__ = name
|
|
122
|
-
return wrapper
|
|
123
|
-
|
|
124
|
-
|
|
125
108
|
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
|
|
126
109
|
Transport = Literal["stdio", "http", "sse", "streamable-http"]
|
|
127
110
|
|
|
@@ -214,8 +197,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
214
197
|
# Resolve server default for background task support
|
|
215
198
|
self._support_tasks_by_default: bool = tasks if tasks is not None else False
|
|
216
199
|
|
|
217
|
-
# Docket
|
|
200
|
+
# Docket and Worker instances (set during lifespan for cross-task access)
|
|
218
201
|
self._docket = None
|
|
202
|
+
self._worker = None
|
|
219
203
|
|
|
220
204
|
self._additional_http_routes: list[BaseRoute] = []
|
|
221
205
|
self._mounted_servers: list[MountedServer] = []
|
|
@@ -437,7 +421,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
437
421
|
isinstance(tool, FunctionTool)
|
|
438
422
|
and tool.task_config.mode != "forbidden"
|
|
439
423
|
):
|
|
440
|
-
docket.register(tool.fn)
|
|
424
|
+
docket.register(tool.fn, names=[tool.key])
|
|
441
425
|
|
|
442
426
|
for prompt in self._prompt_manager._prompts.values():
|
|
443
427
|
if (
|
|
@@ -445,21 +429,24 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
445
429
|
and prompt.task_config.mode != "forbidden"
|
|
446
430
|
):
|
|
447
431
|
# task execution requires async fn (validated at creation time)
|
|
448
|
-
docket.register(
|
|
432
|
+
docket.register(
|
|
433
|
+
cast(Callable[..., Awaitable[Any]], prompt.fn),
|
|
434
|
+
names=[prompt.key],
|
|
435
|
+
)
|
|
449
436
|
|
|
450
437
|
for resource in self._resource_manager._resources.values():
|
|
451
438
|
if (
|
|
452
439
|
isinstance(resource, FunctionResource)
|
|
453
440
|
and resource.task_config.mode != "forbidden"
|
|
454
441
|
):
|
|
455
|
-
docket.register(resource.fn)
|
|
442
|
+
docket.register(resource.fn, names=[resource.name])
|
|
456
443
|
|
|
457
444
|
for template in self._resource_manager._templates.values():
|
|
458
445
|
if (
|
|
459
446
|
isinstance(template, FunctionResourceTemplate)
|
|
460
447
|
and template.task_config.mode != "forbidden"
|
|
461
448
|
):
|
|
462
|
-
docket.register(template.fn)
|
|
449
|
+
docket.register(template.fn, names=[template.name])
|
|
463
450
|
|
|
464
451
|
# Also register functions from mounted servers so tasks can
|
|
465
452
|
# execute in the parent's Docket context
|
|
@@ -482,6 +469,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
482
469
|
|
|
483
470
|
# Create and start Worker
|
|
484
471
|
async with Worker(docket, **worker_kwargs) as worker: # type: ignore[arg-type]
|
|
472
|
+
# Store on server instance for cross-context access
|
|
473
|
+
self._worker = worker
|
|
485
474
|
# Set Worker in ContextVar so CurrentWorker can access it
|
|
486
475
|
worker_token = _current_worker.set(worker)
|
|
487
476
|
try:
|
|
@@ -489,21 +478,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
489
478
|
try:
|
|
490
479
|
yield
|
|
491
480
|
finally:
|
|
492
|
-
# Cancel worker task on exit with timeout to prevent hanging
|
|
493
481
|
worker_task.cancel()
|
|
494
|
-
with suppress(
|
|
495
|
-
|
|
496
|
-
):
|
|
497
|
-
await asyncio.wait_for(worker_task, timeout=2.0)
|
|
482
|
+
with suppress(asyncio.CancelledError):
|
|
483
|
+
await worker_task
|
|
498
484
|
finally:
|
|
499
485
|
_current_worker.reset(worker_token)
|
|
486
|
+
self._worker = None
|
|
500
487
|
finally:
|
|
501
|
-
# Reset ContextVar
|
|
502
488
|
_current_docket.reset(docket_token)
|
|
503
|
-
# Clear instance attribute
|
|
504
489
|
self._docket = None
|
|
505
490
|
finally:
|
|
506
|
-
# Reset server ContextVar
|
|
507
491
|
_current_server.reset(server_token)
|
|
508
492
|
|
|
509
493
|
async def _register_mounted_server_functions(
|
|
@@ -535,8 +519,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
535
519
|
fn_name = f"{prefix}_{tool.key}"
|
|
536
520
|
else:
|
|
537
521
|
fn_name = tool.key
|
|
538
|
-
|
|
539
|
-
docket.register(named_fn)
|
|
522
|
+
docket.register(tool.fn, names=[fn_name])
|
|
540
523
|
|
|
541
524
|
# Register prompts with prefixed names
|
|
542
525
|
for prompt in server._prompt_manager._prompts.values():
|
|
@@ -545,10 +528,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
545
528
|
and prompt.task_config.mode != "forbidden"
|
|
546
529
|
):
|
|
547
530
|
fn_name = f"{prefix}_{prompt.key}" if prefix else prompt.key
|
|
548
|
-
|
|
549
|
-
cast(Callable[..., Awaitable[Any]], prompt.fn),
|
|
531
|
+
docket.register(
|
|
532
|
+
cast(Callable[..., Awaitable[Any]], prompt.fn),
|
|
533
|
+
names=[fn_name],
|
|
550
534
|
)
|
|
551
|
-
docket.register(named_fn)
|
|
552
535
|
|
|
553
536
|
# Register resources with prefixed names (use name, not key/URI)
|
|
554
537
|
for resource in server._resource_manager._resources.values():
|
|
@@ -557,8 +540,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
557
540
|
and resource.task_config.mode != "forbidden"
|
|
558
541
|
):
|
|
559
542
|
fn_name = f"{prefix}_{resource.name}" if prefix else resource.name
|
|
560
|
-
|
|
561
|
-
docket.register(named_fn)
|
|
543
|
+
docket.register(resource.fn, names=[fn_name])
|
|
562
544
|
|
|
563
545
|
# Register resource templates with prefixed names (use name, not key/URI)
|
|
564
546
|
for template in server._resource_manager._templates.values():
|
|
@@ -567,8 +549,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
567
549
|
and template.task_config.mode != "forbidden"
|
|
568
550
|
):
|
|
569
551
|
fn_name = f"{prefix}_{template.name}" if prefix else template.name
|
|
570
|
-
|
|
571
|
-
docket.register(named_fn)
|
|
552
|
+
docket.register(template.fn, names=[fn_name])
|
|
572
553
|
|
|
573
554
|
# Recursively register from nested mounted servers with accumulated prefix
|
|
574
555
|
for nested in server._mounted_servers:
|
|
@@ -584,6 +565,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
584
565
|
@asynccontextmanager
|
|
585
566
|
async def _lifespan_manager(self) -> AsyncIterator[None]:
|
|
586
567
|
if self._lifespan_result_set:
|
|
568
|
+
# Lifespan already ran - ContextVars will be set by Context.__aenter__
|
|
569
|
+
# at request time, so we just yield here.
|
|
587
570
|
yield
|
|
588
571
|
return
|
|
589
572
|
|
|
@@ -2516,10 +2499,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
2516
2499
|
"""
|
|
2517
2500
|
# Display server banner
|
|
2518
2501
|
if show_banner:
|
|
2519
|
-
log_server_banner(
|
|
2520
|
-
server=self,
|
|
2521
|
-
transport="stdio",
|
|
2522
|
-
)
|
|
2502
|
+
log_server_banner(server=self)
|
|
2523
2503
|
|
|
2524
2504
|
with temporary_log_level(log_level):
|
|
2525
2505
|
async with self._lifespan_manager():
|
|
@@ -2582,22 +2562,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
2582
2562
|
stateless_http=stateless_http,
|
|
2583
2563
|
)
|
|
2584
2564
|
|
|
2585
|
-
# Get the path for the server URL
|
|
2586
|
-
server_path = (
|
|
2587
|
-
app.state.path.lstrip("/")
|
|
2588
|
-
if hasattr(app, "state") and hasattr(app.state, "path")
|
|
2589
|
-
else path or ""
|
|
2590
|
-
)
|
|
2591
|
-
|
|
2592
2565
|
# Display server banner
|
|
2593
2566
|
if show_banner:
|
|
2594
|
-
log_server_banner(
|
|
2595
|
-
server=self,
|
|
2596
|
-
transport=transport,
|
|
2597
|
-
host=host,
|
|
2598
|
-
port=port,
|
|
2599
|
-
path=server_path,
|
|
2600
|
-
)
|
|
2567
|
+
log_server_banner(server=self)
|
|
2601
2568
|
uvicorn_config_from_user = uvicorn_config or {}
|
|
2602
2569
|
|
|
2603
2570
|
config_kwargs: dict[str, Any] = {
|
fastmcp/server/tasks/handlers.py
CHANGED
|
@@ -56,6 +56,7 @@ async def handle_tool_as_task(
|
|
|
56
56
|
ctx = get_context()
|
|
57
57
|
session_id = ctx.session_id
|
|
58
58
|
|
|
59
|
+
# Get Docket from ContextVar (set by Context.__aenter__ at request time)
|
|
59
60
|
docket = _current_docket.get()
|
|
60
61
|
if docket is None:
|
|
61
62
|
raise McpError(
|
|
@@ -72,13 +73,15 @@ async def handle_tool_as_task(
|
|
|
72
73
|
tool = await server.get_tool(tool_name)
|
|
73
74
|
|
|
74
75
|
# Store task key mapping and creation timestamp in Redis for protocol handlers
|
|
75
|
-
|
|
76
|
-
created_at_key =
|
|
76
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
|
|
77
|
+
created_at_key = docket.key(
|
|
78
|
+
f"fastmcp:task:{session_id}:{server_task_id}:created_at"
|
|
79
|
+
)
|
|
77
80
|
ttl_seconds = int(
|
|
78
81
|
docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
|
|
79
82
|
)
|
|
80
83
|
async with docket.redis() as redis:
|
|
81
|
-
await redis.set(
|
|
84
|
+
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
|
|
82
85
|
await redis.set(created_at_key, created_at, ex=ttl_seconds)
|
|
83
86
|
|
|
84
87
|
# Send notifications/tasks/created per SEP-1686 (mandatory)
|
|
@@ -165,6 +168,7 @@ async def handle_prompt_as_task(
|
|
|
165
168
|
ctx = get_context()
|
|
166
169
|
session_id = ctx.session_id
|
|
167
170
|
|
|
171
|
+
# Get Docket from ContextVar (set by Context.__aenter__ at request time)
|
|
168
172
|
docket = _current_docket.get()
|
|
169
173
|
if docket is None:
|
|
170
174
|
raise McpError(
|
|
@@ -181,13 +185,15 @@ async def handle_prompt_as_task(
|
|
|
181
185
|
prompt = await server.get_prompt(prompt_name)
|
|
182
186
|
|
|
183
187
|
# Store task key mapping and creation timestamp in Redis for protocol handlers
|
|
184
|
-
|
|
185
|
-
created_at_key =
|
|
188
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
|
|
189
|
+
created_at_key = docket.key(
|
|
190
|
+
f"fastmcp:task:{session_id}:{server_task_id}:created_at"
|
|
191
|
+
)
|
|
186
192
|
ttl_seconds = int(
|
|
187
193
|
docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
|
|
188
194
|
)
|
|
189
195
|
async with docket.redis() as redis:
|
|
190
|
-
await redis.set(
|
|
196
|
+
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
|
|
191
197
|
await redis.set(created_at_key, created_at, ex=ttl_seconds)
|
|
192
198
|
|
|
193
199
|
# Send notifications/tasks/created per SEP-1686 (mandatory)
|
|
@@ -272,12 +278,13 @@ async def handle_resource_as_task(
|
|
|
272
278
|
ctx = get_context()
|
|
273
279
|
session_id = ctx.session_id
|
|
274
280
|
|
|
281
|
+
# Get Docket from ContextVar (set by Context.__aenter__ at request time)
|
|
275
282
|
docket = _current_docket.get()
|
|
276
283
|
if docket is None:
|
|
277
284
|
raise McpError(
|
|
278
285
|
ErrorData(
|
|
279
286
|
code=INTERNAL_ERROR,
|
|
280
|
-
message="Background tasks require
|
|
287
|
+
message="Background tasks require a running FastMCP server context",
|
|
281
288
|
)
|
|
282
289
|
)
|
|
283
290
|
|
|
@@ -285,13 +292,15 @@ async def handle_resource_as_task(
|
|
|
285
292
|
task_key = build_task_key(session_id, server_task_id, "resource", str(uri))
|
|
286
293
|
|
|
287
294
|
# Store task key mapping and creation timestamp in Redis for protocol handlers
|
|
288
|
-
|
|
289
|
-
created_at_key =
|
|
295
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}")
|
|
296
|
+
created_at_key = docket.key(
|
|
297
|
+
f"fastmcp:task:{session_id}:{server_task_id}:created_at"
|
|
298
|
+
)
|
|
290
299
|
ttl_seconds = int(
|
|
291
300
|
docket.execution_ttl.total_seconds() + TASK_MAPPING_TTL_BUFFER_SECONDS
|
|
292
301
|
)
|
|
293
302
|
async with docket.redis() as redis:
|
|
294
|
-
await redis.set(
|
|
303
|
+
await redis.set(task_meta_key, task_key, ex=ttl_seconds)
|
|
295
304
|
await redis.set(created_at_key, created_at, ex=ttl_seconds)
|
|
296
305
|
|
|
297
306
|
# Send notifications/tasks/created per SEP-1686 (mandatory)
|
fastmcp/server/tasks/protocol.py
CHANGED
|
@@ -77,10 +77,12 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
# Look up full task key and creation timestamp from Redis
|
|
80
|
-
|
|
81
|
-
created_at_key =
|
|
80
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
|
|
81
|
+
created_at_key = docket.key(
|
|
82
|
+
f"fastmcp:task:{session_id}:{client_task_id}:created_at"
|
|
83
|
+
)
|
|
82
84
|
async with docket.redis() as redis:
|
|
83
|
-
task_key_bytes = await redis.get(
|
|
85
|
+
task_key_bytes = await redis.get(task_meta_key)
|
|
84
86
|
created_at_bytes = await redis.get(created_at_key)
|
|
85
87
|
|
|
86
88
|
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
|
|
@@ -176,9 +178,9 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|
|
176
178
|
)
|
|
177
179
|
|
|
178
180
|
# Look up full task key from Redis
|
|
179
|
-
|
|
181
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
|
|
180
182
|
async with docket.redis() as redis:
|
|
181
|
-
task_key_bytes = await redis.get(
|
|
183
|
+
task_key_bytes = await redis.get(task_meta_key)
|
|
182
184
|
|
|
183
185
|
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
|
|
184
186
|
|
|
@@ -309,10 +311,12 @@ async def tasks_cancel_handler(
|
|
|
309
311
|
)
|
|
310
312
|
|
|
311
313
|
# Look up full task key and creation timestamp from Redis
|
|
312
|
-
|
|
313
|
-
created_at_key =
|
|
314
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
|
|
315
|
+
created_at_key = docket.key(
|
|
316
|
+
f"fastmcp:task:{session_id}:{client_task_id}:created_at"
|
|
317
|
+
)
|
|
314
318
|
async with docket.redis() as redis:
|
|
315
|
-
task_key_bytes = await redis.get(
|
|
319
|
+
task_key_bytes = await redis.get(task_meta_key)
|
|
316
320
|
created_at_bytes = await redis.get(created_at_key)
|
|
317
321
|
|
|
318
322
|
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
|
|
@@ -70,7 +70,7 @@ async def subscribe_to_task_updates(
|
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
except Exception as e:
|
|
73
|
-
logger.
|
|
73
|
+
logger.error(f"subscribe_to_task_updates failed for {task_id}: {e}")
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
async def _send_status_notification(
|
|
@@ -101,8 +101,7 @@ async def _send_status_notification(
|
|
|
101
101
|
key_parts = parse_task_key(task_key)
|
|
102
102
|
session_id = key_parts["session_id"]
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
|
|
104
|
+
created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at")
|
|
106
105
|
async with docket.redis() as redis:
|
|
107
106
|
created_at_bytes = await redis.get(created_at_key)
|
|
108
107
|
|
|
@@ -175,8 +174,7 @@ async def _send_progress_notification(
|
|
|
175
174
|
key_parts = parse_task_key(task_key)
|
|
176
175
|
session_id = key_parts["session_id"]
|
|
177
176
|
|
|
178
|
-
|
|
179
|
-
created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
|
|
177
|
+
created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at")
|
|
180
178
|
async with docket.redis() as redis:
|
|
181
179
|
created_at_bytes = await redis.get(created_at_key)
|
|
182
180
|
|
fastmcp/settings.py
CHANGED
|
@@ -392,6 +392,21 @@ class Settings(BaseSettings):
|
|
|
392
392
|
),
|
|
393
393
|
] = True
|
|
394
394
|
|
|
395
|
+
check_for_updates: Annotated[
|
|
396
|
+
Literal["stable", "prerelease", "off"],
|
|
397
|
+
Field(
|
|
398
|
+
description=inspect.cleandoc(
|
|
399
|
+
"""
|
|
400
|
+
Controls update checking when displaying the CLI banner.
|
|
401
|
+
- "stable": Check for stable releases only (default)
|
|
402
|
+
- "prerelease": Also check for pre-release versions (alpha, beta, rc)
|
|
403
|
+
- "off": Disable update checking entirely
|
|
404
|
+
Set via FASTMCP_CHECK_FOR_UPDATES environment variable.
|
|
405
|
+
"""
|
|
406
|
+
),
|
|
407
|
+
),
|
|
408
|
+
] = "stable"
|
|
409
|
+
|
|
395
410
|
@property
|
|
396
411
|
def server_auth_class(self) -> AuthProvider | None:
|
|
397
412
|
from fastmcp.utilities.types import get_cached_typeadapter
|
fastmcp/tools/tool.py
CHANGED
|
@@ -32,7 +32,7 @@ import fastmcp
|
|
|
32
32
|
from fastmcp.server.dependencies import get_context, without_injected_parameters
|
|
33
33
|
from fastmcp.server.tasks.config import TaskConfig
|
|
34
34
|
from fastmcp.utilities.components import FastMCPComponent
|
|
35
|
-
from fastmcp.utilities.json_schema import compress_schema
|
|
35
|
+
from fastmcp.utilities.json_schema import compress_schema, resolve_root_ref
|
|
36
36
|
from fastmcp.utilities.logging import get_logger
|
|
37
37
|
from fastmcp.utilities.types import (
|
|
38
38
|
Audio,
|
|
@@ -559,6 +559,10 @@ class ParsedFunction:
|
|
|
559
559
|
|
|
560
560
|
output_schema = compress_schema(output_schema, prune_titles=True)
|
|
561
561
|
|
|
562
|
+
# Resolve root-level $ref to meet MCP spec requirement for type: object
|
|
563
|
+
# Self-referential Pydantic models generate schemas with $ref at root
|
|
564
|
+
output_schema = resolve_root_ref(output_schema)
|
|
565
|
+
|
|
562
566
|
except PydanticSchemaGenerationError as e:
|
|
563
567
|
if "_UnserializableType" not in str(e):
|
|
564
568
|
logger.debug(f"Unable to generate schema for type {output_type!r}")
|
fastmcp/tools/tool_manager.py
CHANGED
|
@@ -8,7 +8,7 @@ from mcp.types import ToolAnnotations
|
|
|
8
8
|
from pydantic import ValidationError
|
|
9
9
|
|
|
10
10
|
from fastmcp import settings
|
|
11
|
-
from fastmcp.exceptions import NotFoundError, ToolError
|
|
11
|
+
from fastmcp.exceptions import FastMCPError, NotFoundError, ToolError
|
|
12
12
|
from fastmcp.settings import DuplicateBehavior
|
|
13
13
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
14
14
|
from fastmcp.tools.tool_transform import (
|
|
@@ -158,12 +158,10 @@ class ToolManager:
|
|
|
158
158
|
tool = await self.get_tool(key)
|
|
159
159
|
try:
|
|
160
160
|
return await tool.run(arguments)
|
|
161
|
-
except
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
logger.exception(f"Error calling tool {key!r}")
|
|
166
|
-
raise e
|
|
161
|
+
except FastMCPError:
|
|
162
|
+
raise
|
|
163
|
+
except ValidationError:
|
|
164
|
+
raise
|
|
167
165
|
except Exception as e:
|
|
168
166
|
logger.exception(f"Error calling tool {key!r}")
|
|
169
167
|
if self.mask_error_details:
|
fastmcp/utilities/cli.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import TYPE_CHECKING, Any
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
7
|
|
|
8
8
|
from pydantic import ValidationError
|
|
9
9
|
from rich.align import Align
|
|
@@ -17,6 +17,7 @@ from fastmcp.utilities.logging import get_logger
|
|
|
17
17
|
from fastmcp.utilities.mcp_server_config import MCPServerConfig
|
|
18
18
|
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
|
|
19
19
|
from fastmcp.utilities.types import get_cached_typeadapter
|
|
20
|
+
from fastmcp.utilities.version_check import check_for_newer_version
|
|
20
21
|
|
|
21
22
|
if TYPE_CHECKING:
|
|
22
23
|
from fastmcp import FastMCP
|
|
@@ -197,23 +198,11 @@ LOGO_ASCII_4 = (
|
|
|
197
198
|
)
|
|
198
199
|
|
|
199
200
|
|
|
200
|
-
def log_server_banner(
|
|
201
|
-
server
|
|
202
|
-
transport: Literal["stdio", "http", "sse", "streamable-http"],
|
|
203
|
-
*,
|
|
204
|
-
host: str | None = None,
|
|
205
|
-
port: int | None = None,
|
|
206
|
-
path: str | None = None,
|
|
207
|
-
) -> None:
|
|
208
|
-
"""Creates and logs a formatted banner with server information and logo.
|
|
201
|
+
def log_server_banner(server: FastMCP[Any]) -> None:
|
|
202
|
+
"""Creates and logs a formatted banner with server information and logo."""
|
|
209
203
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
server_name: Optional server name to display
|
|
213
|
-
host: Host address (for HTTP transports)
|
|
214
|
-
port: Port number (for HTTP transports)
|
|
215
|
-
path: Server path (for HTTP transports)
|
|
216
|
-
"""
|
|
204
|
+
# Check for updates (non-blocking, fails silently)
|
|
205
|
+
newer_version = check_for_newer_version()
|
|
217
206
|
|
|
218
207
|
# Create the logo text
|
|
219
208
|
# Use Text with no_wrap and markup disabled to preserve ANSI escape codes
|
|
@@ -228,39 +217,36 @@ def log_server_banner(
|
|
|
228
217
|
info_table.add_column(style="cyan", justify="left") # Label column
|
|
229
218
|
info_table.add_column(style="dim", justify="left") # Value column
|
|
230
219
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
display_transport = "HTTP"
|
|
234
|
-
case "sse":
|
|
235
|
-
display_transport = "SSE"
|
|
236
|
-
case "stdio":
|
|
237
|
-
display_transport = "STDIO"
|
|
238
|
-
|
|
239
|
-
info_table.add_row("🖥", "Server name:", Text(server.name + "\n", style="bold blue"))
|
|
240
|
-
info_table.add_row("📦", "Transport:", display_transport)
|
|
241
|
-
|
|
242
|
-
# Show connection info based on transport
|
|
243
|
-
if transport in ("http", "streamable-http", "sse") and host and port:
|
|
244
|
-
server_url = f"http://{host}:{port}"
|
|
245
|
-
if path:
|
|
246
|
-
server_url += f"/{path.lstrip('/')}"
|
|
247
|
-
info_table.add_row("🔗", "Server URL:", server_url)
|
|
248
|
-
|
|
249
|
-
# Add documentation link
|
|
250
|
-
info_table.add_row("", "", "")
|
|
251
|
-
info_table.add_row("📚", "Docs:", "https://gofastmcp.com")
|
|
252
|
-
info_table.add_row("🚀", "Hosting:", "https://fastmcp.cloud")
|
|
220
|
+
info_table.add_row("🖥", "Server:", Text(server.name, style="dim"))
|
|
221
|
+
info_table.add_row("🚀", "Deploy free:", "https://fastmcp.cloud")
|
|
253
222
|
|
|
254
223
|
# Create panel with logo, title, and information using Group
|
|
224
|
+
docs_url = Text("https://gofastmcp.com", style="dim")
|
|
255
225
|
panel_content = Group(
|
|
226
|
+
"",
|
|
256
227
|
Align.center(logo_text),
|
|
257
228
|
"",
|
|
258
|
-
Align.center(title_text),
|
|
259
229
|
"",
|
|
230
|
+
Align.center(title_text),
|
|
231
|
+
Align.center(docs_url),
|
|
260
232
|
"",
|
|
261
233
|
Align.center(info_table),
|
|
262
234
|
)
|
|
263
235
|
|
|
236
|
+
# v3 notice banner (shown below main panel)
|
|
237
|
+
v3_line1 = Text("✨ FastMCP 3.0 is coming!", style="bold")
|
|
238
|
+
v3_line2 = Text.assemble(
|
|
239
|
+
("Pin ", "dim"),
|
|
240
|
+
("`fastmcp < 3`", "dim bold"),
|
|
241
|
+
(" in production, then upgrade when you're ready.", "dim"),
|
|
242
|
+
)
|
|
243
|
+
v3_notice = Panel(
|
|
244
|
+
Group(Align.center(v3_line1), Align.center(v3_line2)),
|
|
245
|
+
border_style="blue",
|
|
246
|
+
padding=(0, 2),
|
|
247
|
+
width=80,
|
|
248
|
+
)
|
|
249
|
+
|
|
264
250
|
panel = Panel(
|
|
265
251
|
panel_content,
|
|
266
252
|
border_style="dim",
|
|
@@ -270,5 +256,26 @@ def log_server_banner(
|
|
|
270
256
|
)
|
|
271
257
|
|
|
272
258
|
console = Console(stderr=True)
|
|
273
|
-
|
|
274
|
-
|
|
259
|
+
|
|
260
|
+
# Build output elements
|
|
261
|
+
output_elements: list[Align | Panel | str] = ["\n", Align.center(panel)]
|
|
262
|
+
output_elements.append(Align.center(v3_notice))
|
|
263
|
+
|
|
264
|
+
# Add update notice if a newer version is available (shown last for visibility)
|
|
265
|
+
if newer_version:
|
|
266
|
+
update_line1 = Text.assemble(
|
|
267
|
+
("🎉 Update available: ", "bold"),
|
|
268
|
+
(newer_version, "bold green"),
|
|
269
|
+
)
|
|
270
|
+
update_line2 = Text("Run: pip install --upgrade fastmcp", style="dim")
|
|
271
|
+
update_notice = Panel(
|
|
272
|
+
Group(Align.center(update_line1), Align.center(update_line2)),
|
|
273
|
+
border_style="blue",
|
|
274
|
+
padding=(0, 2),
|
|
275
|
+
width=80,
|
|
276
|
+
)
|
|
277
|
+
output_elements.append(Align.center(update_notice))
|
|
278
|
+
|
|
279
|
+
output_elements.append("\n")
|
|
280
|
+
|
|
281
|
+
console.print(Group(*output_elements))
|
fastmcp/utilities/json_schema.py
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections import defaultdict
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
|
|
8
|
+
"""Resolve $ref at root level to meet MCP spec requirements.
|
|
9
|
+
|
|
10
|
+
MCP specification requires outputSchema to have "type": "object" at the root level.
|
|
11
|
+
When Pydantic generates schemas for self-referential models, it uses $ref at the
|
|
12
|
+
root level pointing to $defs. This function resolves such references by inlining
|
|
13
|
+
the referenced definition while preserving $defs for nested references.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
schema: JSON schema dict that may have $ref at root level
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A new schema dict with root-level $ref resolved, or the original schema
|
|
20
|
+
if no resolution is needed
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> schema = {
|
|
24
|
+
... "$defs": {"Node": {"type": "object", "properties": {...}}},
|
|
25
|
+
... "$ref": "#/$defs/Node"
|
|
26
|
+
... }
|
|
27
|
+
>>> resolved = resolve_root_ref(schema)
|
|
28
|
+
>>> # Result: {"type": "object", "properties": {...}, "$defs": {...}}
|
|
29
|
+
"""
|
|
30
|
+
# Only resolve if we have $ref at root level with $defs but no explicit type
|
|
31
|
+
if "$ref" in schema and "$defs" in schema and "type" not in schema:
|
|
32
|
+
ref = schema["$ref"]
|
|
33
|
+
# Only handle local $defs references
|
|
34
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
35
|
+
def_name = ref.split("/")[-1]
|
|
36
|
+
defs = schema["$defs"]
|
|
37
|
+
if def_name in defs:
|
|
38
|
+
# Create a new schema by copying the referenced definition
|
|
39
|
+
resolved = dict(defs[def_name])
|
|
40
|
+
# Preserve $defs for nested references (other fields may still use them)
|
|
41
|
+
resolved["$defs"] = defs
|
|
42
|
+
return resolved
|
|
43
|
+
return schema
|
|
4
44
|
|
|
5
45
|
|
|
6
46
|
def _prune_param(schema: dict, param: str) -> dict:
|