fastmcp 2.11.2__py3-none-any.whl → 2.11.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/client/logging.py +25 -1
- fastmcp/client/transports.py +4 -3
- fastmcp/experimental/server/openapi/routing.py +1 -1
- fastmcp/experimental/server/openapi/server.py +10 -23
- fastmcp/experimental/utilities/openapi/__init__.py +2 -2
- fastmcp/experimental/utilities/openapi/formatters.py +34 -0
- fastmcp/experimental/utilities/openapi/models.py +5 -2
- fastmcp/experimental/utilities/openapi/parser.py +248 -70
- fastmcp/experimental/utilities/openapi/schemas.py +135 -106
- fastmcp/prompts/prompt_manager.py +2 -2
- fastmcp/resources/resource_manager.py +12 -6
- fastmcp/server/auth/__init__.py +9 -1
- fastmcp/server/auth/auth.py +17 -1
- fastmcp/server/auth/providers/jwt.py +3 -4
- fastmcp/server/auth/registry.py +1 -1
- fastmcp/server/dependencies.py +32 -2
- fastmcp/server/http.py +41 -34
- fastmcp/server/server.py +9 -5
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +7 -7
- fastmcp/tools/tool_manager.py +3 -1
- fastmcp/tools/tool_transform.py +41 -27
- fastmcp/utilities/components.py +19 -4
- fastmcp/utilities/openapi.py +4 -4
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/METADATA +2 -2
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/RECORD +29 -29
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -75,7 +75,7 @@ def _replace_ref_with_defs(
|
|
|
75
75
|
info: dict[str, Any], description: str | None = None
|
|
76
76
|
) -> dict[str, Any]:
|
|
77
77
|
"""
|
|
78
|
-
Replace openapi $ref with jsonschema $defs
|
|
78
|
+
Replace openapi $ref with jsonschema $defs recursively.
|
|
79
79
|
|
|
80
80
|
Examples:
|
|
81
81
|
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
|
@@ -202,6 +202,7 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
202
202
|
|
|
203
203
|
def _combine_schemas_and_map_params(
|
|
204
204
|
route: HTTPRoute,
|
|
205
|
+
convert_refs: bool = True,
|
|
205
206
|
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
|
|
206
207
|
"""
|
|
207
208
|
Combines parameter and request body schemas into a single schema.
|
|
@@ -233,10 +234,43 @@ def _combine_schemas_and_map_params(
|
|
|
233
234
|
|
|
234
235
|
if route.request_body and route.request_body.content_schema:
|
|
235
236
|
content_type = next(iter(route.request_body.content_schema))
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
|
|
238
|
+
# Convert refs if needed
|
|
239
|
+
if convert_refs:
|
|
240
|
+
body_schema = _replace_ref_with_defs(
|
|
241
|
+
route.request_body.content_schema[content_type]
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
body_schema = route.request_body.content_schema[content_type]
|
|
245
|
+
|
|
246
|
+
if route.request_body.description and not body_schema.get("description"):
|
|
247
|
+
body_schema["description"] = route.request_body.description
|
|
248
|
+
|
|
249
|
+
# Handle allOf at the top level by merging all schemas
|
|
250
|
+
if "allOf" in body_schema and isinstance(body_schema["allOf"], list):
|
|
251
|
+
merged_props = {}
|
|
252
|
+
merged_required = []
|
|
253
|
+
|
|
254
|
+
for sub_schema in body_schema["allOf"]:
|
|
255
|
+
if isinstance(sub_schema, dict):
|
|
256
|
+
# Merge properties
|
|
257
|
+
if "properties" in sub_schema:
|
|
258
|
+
merged_props.update(sub_schema["properties"])
|
|
259
|
+
# Merge required fields
|
|
260
|
+
if "required" in sub_schema:
|
|
261
|
+
merged_required.extend(sub_schema["required"])
|
|
262
|
+
|
|
263
|
+
# Update body_schema with merged properties
|
|
264
|
+
body_schema["properties"] = merged_props
|
|
265
|
+
if merged_required:
|
|
266
|
+
# Remove duplicates while preserving order
|
|
267
|
+
seen = set()
|
|
268
|
+
body_schema["required"] = [
|
|
269
|
+
x for x in merged_required if not (x in seen or seen.add(x))
|
|
270
|
+
]
|
|
271
|
+
# Remove the allOf since we've merged it
|
|
272
|
+
body_schema.pop("allOf", None)
|
|
273
|
+
|
|
240
274
|
body_props = body_schema.get("properties", {})
|
|
241
275
|
|
|
242
276
|
# Detect collisions: parameters that exist in both body and path/query/header
|
|
@@ -261,10 +295,11 @@ def _combine_schemas_and_map_params(
|
|
|
261
295
|
"openapi_name": param.name,
|
|
262
296
|
}
|
|
263
297
|
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
param.schema_
|
|
267
|
-
|
|
298
|
+
# Convert refs if needed
|
|
299
|
+
if convert_refs:
|
|
300
|
+
param_schema = _replace_ref_with_defs(param.schema_)
|
|
301
|
+
else:
|
|
302
|
+
param_schema = param.schema_
|
|
268
303
|
original_desc = param_schema.get("description", "")
|
|
269
304
|
location_desc = f"({param.location.capitalize()} parameter)"
|
|
270
305
|
if original_desc:
|
|
@@ -287,9 +322,11 @@ def _combine_schemas_and_map_params(
|
|
|
287
322
|
"openapi_name": param.name,
|
|
288
323
|
}
|
|
289
324
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
325
|
+
# Convert refs if needed
|
|
326
|
+
if convert_refs:
|
|
327
|
+
param_schema = _replace_ref_with_defs(param.schema_)
|
|
328
|
+
else:
|
|
329
|
+
param_schema = param.schema_
|
|
293
330
|
|
|
294
331
|
# Don't make optional parameters nullable - they can simply be omitted
|
|
295
332
|
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
@@ -298,14 +335,28 @@ def _combine_schemas_and_map_params(
|
|
|
298
335
|
|
|
299
336
|
# Add request body properties (no suffixes for body parameters)
|
|
300
337
|
if route.request_body and route.request_body.content_schema:
|
|
301
|
-
|
|
302
|
-
|
|
338
|
+
# If body is just a $ref, we need to handle it differently
|
|
339
|
+
if "$ref" in body_schema and not body_props:
|
|
340
|
+
# The entire body is a reference to a schema
|
|
341
|
+
# We need to expand this inline or keep the ref
|
|
342
|
+
# For simplicity, we'll keep it as a single property
|
|
343
|
+
properties["body"] = body_schema
|
|
344
|
+
if route.request_body.required:
|
|
345
|
+
required.append("body")
|
|
346
|
+
parameter_map["body"] = {"location": "body", "openapi_name": "body"}
|
|
347
|
+
else:
|
|
348
|
+
# Normal case: body has properties
|
|
349
|
+
for prop_name, prop_schema in body_props.items():
|
|
350
|
+
properties[prop_name] = prop_schema
|
|
303
351
|
|
|
304
|
-
|
|
305
|
-
|
|
352
|
+
# Track parameter mapping for body properties
|
|
353
|
+
parameter_map[prop_name] = {
|
|
354
|
+
"location": "body",
|
|
355
|
+
"openapi_name": prop_name,
|
|
356
|
+
}
|
|
306
357
|
|
|
307
|
-
|
|
308
|
-
|
|
358
|
+
if route.request_body.required:
|
|
359
|
+
required.extend(body_schema.get("required", []))
|
|
309
360
|
|
|
310
361
|
result = {
|
|
311
362
|
"type": "object",
|
|
@@ -313,43 +364,53 @@ def _combine_schemas_and_map_params(
|
|
|
313
364
|
"required": required,
|
|
314
365
|
}
|
|
315
366
|
# Add schema definitions if available
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
367
|
+
schema_defs = route.request_schemas
|
|
368
|
+
if schema_defs:
|
|
369
|
+
if convert_refs:
|
|
370
|
+
# Need to convert refs and prune
|
|
371
|
+
all_defs = schema_defs.copy()
|
|
372
|
+
# Convert each schema definition recursively
|
|
373
|
+
for name, schema in all_defs.items():
|
|
374
|
+
if isinstance(schema, dict):
|
|
375
|
+
all_defs[name] = _replace_ref_with_defs(schema)
|
|
376
|
+
|
|
377
|
+
# Prune to only needed schemas
|
|
378
|
+
used_refs = set()
|
|
379
|
+
|
|
380
|
+
def find_refs_in_value(value):
|
|
381
|
+
"""Recursively find all $ref references."""
|
|
382
|
+
if isinstance(value, dict):
|
|
383
|
+
if "$ref" in value and isinstance(value["$ref"], str):
|
|
384
|
+
ref = value["$ref"]
|
|
385
|
+
if ref.startswith("#/$defs/"):
|
|
386
|
+
used_refs.add(ref.split("/")[-1])
|
|
387
|
+
for v in value.values():
|
|
388
|
+
find_refs_in_value(v)
|
|
389
|
+
elif isinstance(value, list):
|
|
390
|
+
for item in value:
|
|
391
|
+
find_refs_in_value(item)
|
|
392
|
+
|
|
393
|
+
# Find refs in properties
|
|
394
|
+
find_refs_in_value(properties)
|
|
395
|
+
|
|
396
|
+
# Collect transitive dependencies
|
|
397
|
+
if used_refs:
|
|
398
|
+
collected_all = False
|
|
399
|
+
while not collected_all:
|
|
400
|
+
initial_count = len(used_refs)
|
|
401
|
+
for name in list(used_refs):
|
|
402
|
+
if name in all_defs:
|
|
403
|
+
find_refs_in_value(all_defs[name])
|
|
404
|
+
collected_all = len(used_refs) == initial_count
|
|
405
|
+
|
|
406
|
+
result["$defs"] = {
|
|
407
|
+
name: def_schema
|
|
408
|
+
for name, def_schema in all_defs.items()
|
|
409
|
+
if name in used_refs
|
|
410
|
+
}
|
|
351
411
|
else:
|
|
352
|
-
|
|
412
|
+
# From parser - already converted and pruned
|
|
413
|
+
result["$defs"] = schema_defs
|
|
353
414
|
|
|
354
415
|
return result, parameter_map
|
|
355
416
|
|
|
@@ -440,17 +501,17 @@ def extract_output_schema_from_responses(
|
|
|
440
501
|
if not schema or not isinstance(schema, dict):
|
|
441
502
|
return None
|
|
442
503
|
|
|
443
|
-
#
|
|
444
|
-
output_schema = schema
|
|
504
|
+
# Convert refs if needed
|
|
505
|
+
output_schema = _replace_ref_with_defs(schema)
|
|
445
506
|
|
|
446
507
|
# If schema has a $ref, resolve it first before processing nullable fields
|
|
447
508
|
if "$ref" in output_schema and schema_definitions:
|
|
448
509
|
ref_path = output_schema["$ref"]
|
|
449
|
-
if ref_path.startswith("
|
|
510
|
+
if ref_path.startswith("#/$defs/"):
|
|
450
511
|
schema_name = ref_path.split("/")[-1]
|
|
451
512
|
if schema_name in schema_definitions:
|
|
452
513
|
# Replace $ref with the actual schema definition
|
|
453
|
-
output_schema = schema_definitions[schema_name]
|
|
514
|
+
output_schema = _replace_ref_with_defs(schema_definitions[schema_name])
|
|
454
515
|
|
|
455
516
|
# Convert OpenAPI schema to JSON Schema format
|
|
456
517
|
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
@@ -473,56 +534,25 @@ def extract_output_schema_from_responses(
|
|
|
473
534
|
}
|
|
474
535
|
output_schema = wrapped_schema
|
|
475
536
|
|
|
476
|
-
# Add schema definitions if available
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
processed_defs =
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if
|
|
483
|
-
|
|
484
|
-
|
|
537
|
+
# Add schema definitions if available
|
|
538
|
+
if schema_definitions:
|
|
539
|
+
# Convert refs if needed
|
|
540
|
+
processed_defs = schema_definitions.copy()
|
|
541
|
+
# Convert each schema definition recursively
|
|
542
|
+
for name, schema in processed_defs.items():
|
|
543
|
+
if isinstance(schema, dict):
|
|
544
|
+
processed_defs[name] = _replace_ref_with_defs(schema)
|
|
545
|
+
|
|
546
|
+
# Convert OpenAPI schema definitions to JSON Schema format if needed
|
|
547
|
+
if openapi_version and openapi_version.startswith("3.0"):
|
|
548
|
+
from .json_schema_converter import convert_openapi_schema_to_json_schema
|
|
549
|
+
|
|
550
|
+
for def_name in list(processed_defs.keys()):
|
|
485
551
|
processed_defs[def_name] = convert_openapi_schema_to_json_schema(
|
|
486
|
-
|
|
552
|
+
processed_defs[def_name], openapi_version
|
|
487
553
|
)
|
|
488
|
-
else:
|
|
489
|
-
processed_defs[def_name] = def_schema
|
|
490
|
-
output_schema["$defs"] = processed_defs
|
|
491
554
|
|
|
492
|
-
|
|
493
|
-
if output_schema.get("additionalProperties") is False:
|
|
494
|
-
output_schema.pop("additionalProperties")
|
|
495
|
-
|
|
496
|
-
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
497
|
-
if "$defs" in output_schema:
|
|
498
|
-
used_refs = set()
|
|
499
|
-
|
|
500
|
-
def find_refs_in_value(value):
|
|
501
|
-
if isinstance(value, dict):
|
|
502
|
-
if "$ref" in value and isinstance(value["$ref"], str):
|
|
503
|
-
ref = value["$ref"]
|
|
504
|
-
if ref.startswith("#/$defs/"):
|
|
505
|
-
used_refs.add(ref.split("/")[-1])
|
|
506
|
-
for v in value.values():
|
|
507
|
-
find_refs_in_value(v)
|
|
508
|
-
elif isinstance(value, list):
|
|
509
|
-
for item in value:
|
|
510
|
-
find_refs_in_value(item)
|
|
511
|
-
|
|
512
|
-
# Find refs in the main schema (excluding $defs section)
|
|
513
|
-
for key, value in output_schema.items():
|
|
514
|
-
if key != "$defs":
|
|
515
|
-
find_refs_in_value(value)
|
|
516
|
-
|
|
517
|
-
# Remove unused definitions
|
|
518
|
-
if used_refs:
|
|
519
|
-
output_schema["$defs"] = {
|
|
520
|
-
name: def_schema
|
|
521
|
-
for name, def_schema in output_schema["$defs"].items()
|
|
522
|
-
if name in used_refs
|
|
523
|
-
}
|
|
524
|
-
else:
|
|
525
|
-
output_schema.pop("$defs")
|
|
555
|
+
output_schema["$defs"] = processed_defs
|
|
526
556
|
|
|
527
557
|
return output_schema
|
|
528
558
|
|
|
@@ -533,6 +563,5 @@ __all__ = [
|
|
|
533
563
|
"_combine_schemas",
|
|
534
564
|
"_combine_schemas_and_map_params",
|
|
535
565
|
"extract_output_schema_from_responses",
|
|
536
|
-
"_replace_ref_with_defs",
|
|
537
566
|
"_make_optional_parameter_nullable",
|
|
538
567
|
]
|
|
@@ -69,8 +69,8 @@ class PromptManager:
|
|
|
69
69
|
child_dict = {p.key: p for p in child_results}
|
|
70
70
|
if mounted.prefix:
|
|
71
71
|
for prompt in child_dict.values():
|
|
72
|
-
prefixed_prompt = prompt.
|
|
73
|
-
f"{mounted.prefix}_{prompt.key}"
|
|
72
|
+
prefixed_prompt = prompt.model_copy(
|
|
73
|
+
key=f"{mounted.prefix}_{prompt.key}"
|
|
74
74
|
)
|
|
75
75
|
all_prompts[prefixed_prompt.key] = prefixed_prompt
|
|
76
76
|
else:
|
|
@@ -101,8 +101,11 @@ class ResourceManager:
|
|
|
101
101
|
prefixed_uri = add_resource_prefix(
|
|
102
102
|
uri, mounted.prefix, mounted.resource_prefix_format
|
|
103
103
|
)
|
|
104
|
-
# Create a copy of the resource with the prefixed key
|
|
105
|
-
prefixed_resource = resource.
|
|
104
|
+
# Create a copy of the resource with the prefixed key and name
|
|
105
|
+
prefixed_resource = resource.model_copy(
|
|
106
|
+
update={"name": f"{mounted.prefix}_{resource.name}"},
|
|
107
|
+
key=prefixed_uri,
|
|
108
|
+
)
|
|
106
109
|
all_resources[prefixed_uri] = prefixed_resource
|
|
107
110
|
else:
|
|
108
111
|
all_resources.update(child_resources)
|
|
@@ -149,8 +152,11 @@ class ResourceManager:
|
|
|
149
152
|
prefixed_uri_template = add_resource_prefix(
|
|
150
153
|
uri_template, mounted.prefix, mounted.resource_prefix_format
|
|
151
154
|
)
|
|
152
|
-
# Create a copy of the template with the prefixed key
|
|
153
|
-
prefixed_template = template.
|
|
155
|
+
# Create a copy of the template with the prefixed key and name
|
|
156
|
+
prefixed_template = template.model_copy(
|
|
157
|
+
update={"name": f"{mounted.prefix}_{template.name}"},
|
|
158
|
+
key=prefixed_uri_template,
|
|
159
|
+
)
|
|
154
160
|
all_templates[prefixed_uri_template] = prefixed_template
|
|
155
161
|
else:
|
|
156
162
|
all_templates.update(child_dict)
|
|
@@ -273,7 +279,7 @@ class ResourceManager:
|
|
|
273
279
|
Args:
|
|
274
280
|
resource: A Resource instance to add. The resource's .key attribute
|
|
275
281
|
will be used as the storage key. To overwrite it, call
|
|
276
|
-
Resource.
|
|
282
|
+
Resource.model_copy(key=new_key) before calling this method.
|
|
277
283
|
"""
|
|
278
284
|
existing = self._resources.get(resource.key)
|
|
279
285
|
if existing:
|
|
@@ -322,7 +328,7 @@ class ResourceManager:
|
|
|
322
328
|
Args:
|
|
323
329
|
template: A ResourceTemplate instance to add. The template's .key attribute
|
|
324
330
|
will be used as the storage key. To overwrite it, call
|
|
325
|
-
ResourceTemplate.
|
|
331
|
+
ResourceTemplate.model_copy(key=new_key) before calling this method.
|
|
326
332
|
|
|
327
333
|
Returns:
|
|
328
334
|
The added template. If a template with the same URI already exists,
|
fastmcp/server/auth/__init__.py
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
from .auth import
|
|
1
|
+
from .auth import (
|
|
2
|
+
OAuthProvider,
|
|
3
|
+
TokenVerifier,
|
|
4
|
+
RemoteAuthProvider,
|
|
5
|
+
AccessToken,
|
|
6
|
+
AuthProvider,
|
|
7
|
+
)
|
|
2
8
|
from .providers.jwt import JWTVerifier, StaticTokenVerifier
|
|
3
9
|
|
|
4
10
|
|
|
5
11
|
__all__ = [
|
|
12
|
+
"AuthProvider",
|
|
6
13
|
"OAuthProvider",
|
|
7
14
|
"TokenVerifier",
|
|
8
15
|
"JWTVerifier",
|
|
9
16
|
"StaticTokenVerifier",
|
|
10
17
|
"RemoteAuthProvider",
|
|
18
|
+
"AccessToken",
|
|
11
19
|
]
|
|
12
20
|
|
|
13
21
|
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mcp.server.auth.provider import (
|
|
6
|
+
AccessToken as _SDKAccessToken,
|
|
7
|
+
)
|
|
3
8
|
from mcp.server.auth.provider import (
|
|
4
|
-
AccessToken,
|
|
5
9
|
AuthorizationCode,
|
|
6
10
|
OAuthAuthorizationServerProvider,
|
|
7
11
|
RefreshToken,
|
|
@@ -21,6 +25,12 @@ from pydantic import AnyHttpUrl
|
|
|
21
25
|
from starlette.routing import Route
|
|
22
26
|
|
|
23
27
|
|
|
28
|
+
class AccessToken(_SDKAccessToken):
|
|
29
|
+
"""AccessToken that includes all JWT claims."""
|
|
30
|
+
|
|
31
|
+
claims: dict[str, Any] = {}
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
class AuthProvider(TokenVerifierProtocol):
|
|
25
35
|
"""Base class for all FastMCP authentication providers.
|
|
26
36
|
|
|
@@ -130,6 +140,8 @@ class RemoteAuthProvider(AuthProvider):
|
|
|
130
140
|
token_verifier: TokenVerifier,
|
|
131
141
|
authorization_servers: list[AnyHttpUrl],
|
|
132
142
|
resource_server_url: AnyHttpUrl | str,
|
|
143
|
+
resource_name: str | None = None,
|
|
144
|
+
resource_documentation: AnyHttpUrl | None = None,
|
|
133
145
|
):
|
|
134
146
|
"""Initialize the remote auth provider.
|
|
135
147
|
|
|
@@ -143,6 +155,8 @@ class RemoteAuthProvider(AuthProvider):
|
|
|
143
155
|
super().__init__(resource_server_url=resource_server_url)
|
|
144
156
|
self.token_verifier = token_verifier
|
|
145
157
|
self.authorization_servers = authorization_servers
|
|
158
|
+
self.resource_name = resource_name
|
|
159
|
+
self.resource_documentation = resource_documentation
|
|
146
160
|
|
|
147
161
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
148
162
|
"""Verify token using the configured token verifier."""
|
|
@@ -161,6 +175,8 @@ class RemoteAuthProvider(AuthProvider):
|
|
|
161
175
|
resource_url=self.resource_server_url,
|
|
162
176
|
authorization_servers=self.authorization_servers,
|
|
163
177
|
scopes_supported=self.token_verifier.required_scopes,
|
|
178
|
+
resource_name=self.resource_name,
|
|
179
|
+
resource_documentation=self.resource_documentation,
|
|
164
180
|
)
|
|
165
181
|
|
|
166
182
|
|
|
@@ -11,12 +11,11 @@ from authlib.jose import JsonWebKey, JsonWebToken
|
|
|
11
11
|
from authlib.jose.errors import JoseError
|
|
12
12
|
from cryptography.hazmat.primitives import serialization
|
|
13
13
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
14
|
-
from mcp.server.auth.provider import AccessToken
|
|
15
14
|
from pydantic import AnyHttpUrl, SecretStr
|
|
16
15
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
17
16
|
from typing_extensions import TypedDict
|
|
18
17
|
|
|
19
|
-
from fastmcp.server.auth import TokenVerifier
|
|
18
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
20
19
|
from fastmcp.server.auth.registry import register_provider
|
|
21
20
|
from fastmcp.utilities.logging import get_logger
|
|
22
21
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
@@ -108,8 +107,6 @@ class RSAKeyPair:
|
|
|
108
107
|
additional_claims: Any additional claims to include
|
|
109
108
|
kid: Key ID to include in header
|
|
110
109
|
"""
|
|
111
|
-
import time
|
|
112
|
-
|
|
113
110
|
# Create header
|
|
114
111
|
header = {"alg": "RS256"}
|
|
115
112
|
if kid:
|
|
@@ -448,6 +445,7 @@ class JWTVerifier(TokenVerifier):
|
|
|
448
445
|
client_id=str(client_id),
|
|
449
446
|
scopes=scopes,
|
|
450
447
|
expires_at=int(exp) if exp else None,
|
|
448
|
+
claims=claims,
|
|
451
449
|
)
|
|
452
450
|
|
|
453
451
|
except JoseError:
|
|
@@ -535,4 +533,5 @@ class StaticTokenVerifier(TokenVerifier):
|
|
|
535
533
|
client_id=token_data["client_id"],
|
|
536
534
|
scopes=scopes,
|
|
537
535
|
expires_at=expires_at,
|
|
536
|
+
claims=token_data,
|
|
538
537
|
)
|
fastmcp/server/auth/registry.py
CHANGED
|
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
|
|
6
6
|
from typing import TYPE_CHECKING, TypeVar
|
|
7
7
|
|
|
8
8
|
if TYPE_CHECKING:
|
|
9
|
-
from fastmcp.server.auth
|
|
9
|
+
from fastmcp.server.auth import AuthProvider
|
|
10
10
|
|
|
11
11
|
# Type variable for auth providers
|
|
12
12
|
T = TypeVar("T", bound="AuthProvider")
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -2,10 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
|
4
4
|
|
|
5
|
-
from mcp.server.auth.middleware.auth_context import
|
|
6
|
-
|
|
5
|
+
from mcp.server.auth.middleware.auth_context import (
|
|
6
|
+
get_access_token as _sdk_get_access_token,
|
|
7
|
+
)
|
|
7
8
|
from starlette.requests import Request
|
|
8
9
|
|
|
10
|
+
from fastmcp.server.auth import AccessToken
|
|
11
|
+
|
|
9
12
|
if TYPE_CHECKING:
|
|
10
13
|
from fastmcp.server.context import Context
|
|
11
14
|
|
|
@@ -94,3 +97,30 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
|
|
94
97
|
return headers
|
|
95
98
|
except RuntimeError:
|
|
96
99
|
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- Access Token ---
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_access_token() -> AccessToken | None:
|
|
106
|
+
"""
|
|
107
|
+
Get the FastMCP access token from the current context.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The access token if an authenticated user is available, None otherwise.
|
|
111
|
+
"""
|
|
112
|
+
#
|
|
113
|
+
obj = _sdk_get_access_token()
|
|
114
|
+
if obj is None or isinstance(obj, AccessToken):
|
|
115
|
+
return obj
|
|
116
|
+
|
|
117
|
+
# If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible
|
|
118
|
+
# This is a workaround for the case where the SDK returns a different type
|
|
119
|
+
# If it fails, it will raise a TypeError
|
|
120
|
+
try:
|
|
121
|
+
return AccessToken(**obj.model_dump())
|
|
122
|
+
except Exception as e:
|
|
123
|
+
raise TypeError(
|
|
124
|
+
f"Expected fastmcp.server.auth.auth.AccessToken, got {type(obj).__name__}. "
|
|
125
|
+
"Ensure the SDK is using the correct AccessToken type."
|
|
126
|
+
) from e
|
fastmcp/server/http.py
CHANGED
|
@@ -23,7 +23,7 @@ from starlette.responses import Response
|
|
|
23
23
|
from starlette.routing import BaseRoute, Mount, Route
|
|
24
24
|
from starlette.types import Lifespan, Receive, Scope, Send
|
|
25
25
|
|
|
26
|
-
from fastmcp.server.auth
|
|
26
|
+
from fastmcp.server.auth import AuthProvider
|
|
27
27
|
from fastmcp.utilities.logging import get_logger
|
|
28
28
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
@@ -32,6 +32,38 @@ if TYPE_CHECKING:
|
|
|
32
32
|
logger = get_logger(__name__)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
class StreamableHTTPASGIApp:
|
|
36
|
+
"""ASGI application wrapper for Streamable HTTP server transport."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, session_manager):
|
|
39
|
+
self.session_manager = session_manager
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
42
|
+
try:
|
|
43
|
+
await self.session_manager.handle_request(scope, receive, send)
|
|
44
|
+
except RuntimeError as e:
|
|
45
|
+
if str(e) == "Task group is not initialized. Make sure to use run().":
|
|
46
|
+
logger.error(
|
|
47
|
+
f"Original RuntimeError from mcp library: {e}", exc_info=True
|
|
48
|
+
)
|
|
49
|
+
new_error_message = (
|
|
50
|
+
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
|
|
51
|
+
"This commonly occurs when the FastMCP application's lifespan is not "
|
|
52
|
+
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
|
|
53
|
+
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
|
|
54
|
+
"parent app's constructor, where `mcp_app` is the application instance "
|
|
55
|
+
"returned by `fastmcp_instance.http_app()`. \\n"
|
|
56
|
+
"For more details, see the FastMCP ASGI integration documentation: "
|
|
57
|
+
"https://gofastmcp.com/deployment/asgi"
|
|
58
|
+
)
|
|
59
|
+
# Raise a new RuntimeError that includes the original error's message
|
|
60
|
+
# for full context, but leads with the more helpful guidance.
|
|
61
|
+
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
|
|
62
|
+
else:
|
|
63
|
+
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
35
67
|
_current_http_request: ContextVar[Request | None] = ContextVar(
|
|
36
68
|
"http_request",
|
|
37
69
|
default=None,
|
|
@@ -40,7 +72,7 @@ _current_http_request: ContextVar[Request | None] = ContextVar(
|
|
|
40
72
|
|
|
41
73
|
class StarletteWithLifespan(Starlette):
|
|
42
74
|
@property
|
|
43
|
-
def lifespan(self) -> Lifespan:
|
|
75
|
+
def lifespan(self) -> Lifespan[Starlette]:
|
|
44
76
|
return self.router.lifespan_context
|
|
45
77
|
|
|
46
78
|
|
|
@@ -254,33 +286,8 @@ def create_streamable_http_app(
|
|
|
254
286
|
stateless=stateless_http,
|
|
255
287
|
)
|
|
256
288
|
|
|
257
|
-
# Create the ASGI
|
|
258
|
-
|
|
259
|
-
scope: Scope, receive: Receive, send: Send
|
|
260
|
-
) -> None:
|
|
261
|
-
try:
|
|
262
|
-
await session_manager.handle_request(scope, receive, send)
|
|
263
|
-
except RuntimeError as e:
|
|
264
|
-
if str(e) == "Task group is not initialized. Make sure to use run().":
|
|
265
|
-
logger.error(
|
|
266
|
-
f"Original RuntimeError from mcp library: {e}", exc_info=True
|
|
267
|
-
)
|
|
268
|
-
new_error_message = (
|
|
269
|
-
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
|
|
270
|
-
"This commonly occurs when the FastMCP application's lifespan is not "
|
|
271
|
-
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
|
|
272
|
-
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
|
|
273
|
-
"parent app's constructor, where `mcp_app` is the application instance "
|
|
274
|
-
"returned by `fastmcp_instance.http_app()`. \\n"
|
|
275
|
-
"For more details, see the FastMCP ASGI integration documentation: "
|
|
276
|
-
"https://gofastmcp.com/deployment/asgi"
|
|
277
|
-
)
|
|
278
|
-
# Raise a new RuntimeError that includes the original error's message
|
|
279
|
-
# for full context, but leads with the more helpful guidance.
|
|
280
|
-
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
|
|
281
|
-
else:
|
|
282
|
-
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
283
|
-
raise
|
|
289
|
+
# Create the ASGI app wrapper
|
|
290
|
+
streamable_http_app = StreamableHTTPASGIApp(session_manager)
|
|
284
291
|
|
|
285
292
|
# Add StreamableHTTP routes with or without auth
|
|
286
293
|
if auth:
|
|
@@ -305,19 +312,19 @@ def create_streamable_http_app(
|
|
|
305
312
|
|
|
306
313
|
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
|
307
314
|
server_routes.append(
|
|
308
|
-
|
|
315
|
+
Route(
|
|
309
316
|
streamable_http_path,
|
|
310
|
-
|
|
311
|
-
|
|
317
|
+
endpoint=RequireAuthMiddleware(
|
|
318
|
+
streamable_http_app, required_scopes, resource_metadata_url
|
|
312
319
|
),
|
|
313
320
|
)
|
|
314
321
|
)
|
|
315
322
|
else:
|
|
316
323
|
# No auth required
|
|
317
324
|
server_routes.append(
|
|
318
|
-
|
|
325
|
+
Route(
|
|
319
326
|
streamable_http_path,
|
|
320
|
-
|
|
327
|
+
endpoint=streamable_http_app,
|
|
321
328
|
)
|
|
322
329
|
)
|
|
323
330
|
|