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.
@@ -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
- body_schema = _replace_ref_with_defs(
237
- route.request_body.content_schema[content_type].copy(),
238
- route.request_body.description,
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
- # Add location info to description
265
- param_schema = _replace_ref_with_defs(
266
- param.schema_.copy(), param.description
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
- param_schema = _replace_ref_with_defs(
291
- param.schema_.copy(), param.description
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
- for prop_name, prop_schema in body_props.items():
302
- properties[prop_name] = prop_schema
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
- # Track parameter mapping for body properties
305
- parameter_map[prop_name] = {"location": "body", "openapi_name": prop_name}
352
+ # Track parameter mapping for body properties
353
+ parameter_map[prop_name] = {
354
+ "location": "body",
355
+ "openapi_name": prop_name,
356
+ }
306
357
 
307
- if route.request_body.required:
308
- required.extend(body_schema.get("required", []))
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
- if route.schema_definitions:
317
- result["$defs"] = route.schema_definitions.copy()
318
-
319
- # Use lightweight compression - prune additionalProperties and unused definitions
320
- if result.get("additionalProperties") is False:
321
- result.pop("additionalProperties")
322
-
323
- # Remove unused definitions (lightweight approach - just check direct $ref usage)
324
- if "$defs" in result:
325
- used_refs = set()
326
-
327
- def find_refs_in_value(value):
328
- if isinstance(value, dict):
329
- if "$ref" in value and isinstance(value["$ref"], str):
330
- ref = value["$ref"]
331
- if ref.startswith("#/$defs/"):
332
- used_refs.add(ref.split("/")[-1])
333
- for v in value.values():
334
- find_refs_in_value(v)
335
- elif isinstance(value, list):
336
- for item in value:
337
- find_refs_in_value(item)
338
-
339
- # Find refs in the main schema (excluding $defs section)
340
- for key, value in result.items():
341
- if key != "$defs":
342
- find_refs_in_value(value)
343
-
344
- # Remove unused definitions
345
- if used_refs:
346
- result["$defs"] = {
347
- name: def_schema
348
- for name, def_schema in result["$defs"].items()
349
- if name in used_refs
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
- result.pop("$defs")
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
- # Clean and copy the schema
444
- output_schema = schema.copy()
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("#/components/schemas/"):
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].copy()
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 and handle nullable fields in them
477
- # Only add $defs if we didn't resolve the $ref inline above
478
- if schema_definitions and "$ref" not in schema.copy():
479
- processed_defs = {}
480
- for def_name, def_schema in schema_definitions.items():
481
- # Convert OpenAPI schema definitions to JSON Schema format
482
- if openapi_version and openapi_version.startswith("3.0"):
483
- from .json_schema_converter import convert_openapi_schema_to_json_schema
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
- def_schema, openapi_version
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
- # Use lightweight compression - prune additionalProperties and unused definitions
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.with_key(
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.with_key(prefixed_uri)
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.with_key(prefixed_uri_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.with_key() before calling this method.
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.with_key() before calling this method.
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,
@@ -1,13 +1,21 @@
1
- from .auth import OAuthProvider, TokenVerifier, RemoteAuthProvider
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
 
@@ -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
  )
@@ -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.auth import AuthProvider
9
+ from fastmcp.server.auth import AuthProvider
10
10
 
11
11
  # Type variable for auth providers
12
12
  T = TypeVar("T", bound="AuthProvider")
@@ -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 get_access_token
6
- from mcp.server.auth.provider import AccessToken
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.auth import AuthProvider
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 handler
258
- async def handle_streamable_http(
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
- Mount(
315
+ Route(
309
316
  streamable_http_path,
310
- app=RequireAuthMiddleware(
311
- handle_streamable_http, required_scopes, resource_metadata_url
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
- Mount(
325
+ Route(
319
326
  streamable_http_path,
320
- app=handle_streamable_http,
327
+ endpoint=streamable_http_app,
321
328
  )
322
329
  )
323
330