fastmcp 2.13.2__py3-none-any.whl → 2.14.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.
Files changed (74) hide show
  1. fastmcp/__init__.py +0 -21
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +8 -22
  5. fastmcp/cli/install/shared.py +0 -15
  6. fastmcp/cli/tasks.py +110 -0
  7. fastmcp/client/auth/oauth.py +9 -9
  8. fastmcp/client/client.py +665 -129
  9. fastmcp/client/elicitation.py +11 -5
  10. fastmcp/client/messages.py +7 -5
  11. fastmcp/client/roots.py +2 -1
  12. fastmcp/client/tasks.py +614 -0
  13. fastmcp/client/transports.py +37 -5
  14. fastmcp/contrib/component_manager/component_service.py +4 -20
  15. fastmcp/dependencies.py +25 -0
  16. fastmcp/experimental/sampling/handlers/openai.py +1 -1
  17. fastmcp/experimental/server/openapi/__init__.py +15 -13
  18. fastmcp/experimental/utilities/openapi/__init__.py +12 -38
  19. fastmcp/prompts/prompt.py +33 -33
  20. fastmcp/resources/resource.py +29 -12
  21. fastmcp/resources/template.py +64 -54
  22. fastmcp/server/auth/__init__.py +0 -9
  23. fastmcp/server/auth/auth.py +127 -3
  24. fastmcp/server/auth/oauth_proxy.py +47 -97
  25. fastmcp/server/auth/oidc_proxy.py +7 -0
  26. fastmcp/server/auth/providers/in_memory.py +2 -2
  27. fastmcp/server/auth/providers/oci.py +2 -2
  28. fastmcp/server/context.py +66 -72
  29. fastmcp/server/dependencies.py +464 -6
  30. fastmcp/server/elicitation.py +285 -47
  31. fastmcp/server/event_store.py +177 -0
  32. fastmcp/server/http.py +15 -3
  33. fastmcp/server/low_level.py +56 -12
  34. fastmcp/server/middleware/middleware.py +2 -2
  35. fastmcp/server/openapi/__init__.py +35 -0
  36. fastmcp/{experimental/server → server}/openapi/components.py +4 -3
  37. fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
  38. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  39. fastmcp/server/proxy.py +50 -37
  40. fastmcp/server/server.py +731 -532
  41. fastmcp/server/tasks/__init__.py +21 -0
  42. fastmcp/server/tasks/capabilities.py +22 -0
  43. fastmcp/server/tasks/config.py +89 -0
  44. fastmcp/server/tasks/converters.py +205 -0
  45. fastmcp/server/tasks/handlers.py +356 -0
  46. fastmcp/server/tasks/keys.py +93 -0
  47. fastmcp/server/tasks/protocol.py +355 -0
  48. fastmcp/server/tasks/subscriptions.py +205 -0
  49. fastmcp/settings.py +101 -103
  50. fastmcp/tools/tool.py +80 -44
  51. fastmcp/tools/tool_transform.py +1 -12
  52. fastmcp/utilities/components.py +3 -3
  53. fastmcp/utilities/json_schema_type.py +4 -4
  54. fastmcp/utilities/mcp_config.py +1 -2
  55. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  56. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  57. fastmcp/utilities/openapi/__init__.py +63 -0
  58. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  59. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
  60. fastmcp/utilities/tests.py +11 -5
  61. fastmcp/utilities/types.py +8 -0
  62. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/METADATA +5 -4
  63. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/RECORD +71 -59
  64. fastmcp/server/auth/providers/bearer.py +0 -25
  65. fastmcp/server/openapi.py +0 -1087
  66. fastmcp/utilities/openapi.py +0 -1568
  67. /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  68. /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
  69. /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
  70. /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
  71. /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
  72. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +0 -0
  73. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
  74. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import inspect
6
6
  import re
7
7
  from collections.abc import Callable
8
- from typing import Any
8
+ from typing import Annotated, Any
9
9
  from urllib.parse import parse_qs, unquote
10
10
 
11
11
  from mcp.types import Annotations, Icon
@@ -17,13 +17,11 @@ from pydantic import (
17
17
  )
18
18
 
19
19
  from fastmcp.resources.resource import Resource
20
- from fastmcp.server.dependencies import get_context
20
+ from fastmcp.server.dependencies import get_context, without_injected_parameters
21
+ from fastmcp.server.tasks.config import TaskConfig
21
22
  from fastmcp.utilities.components import FastMCPComponent
22
23
  from fastmcp.utilities.json_schema import compress_schema
23
- from fastmcp.utilities.types import (
24
- find_kwarg_by_type,
25
- get_cached_typeadapter,
26
- )
24
+ from fastmcp.utilities.types import get_cached_typeadapter
27
25
 
28
26
 
29
27
  def extract_query_params(uri_template: str) -> set[str]:
@@ -139,6 +137,7 @@ class ResourceTemplate(FastMCPComponent):
139
137
  enabled: bool | None = None,
140
138
  annotations: Annotations | None = None,
141
139
  meta: dict[str, Any] | None = None,
140
+ task: bool | TaskConfig | None = None,
142
141
  ) -> FunctionResourceTemplate:
143
142
  return FunctionResourceTemplate.from_function(
144
143
  fn=fn,
@@ -152,6 +151,7 @@ class ResourceTemplate(FastMCPComponent):
152
151
  enabled=enabled,
153
152
  annotations=annotations,
154
153
  meta=meta,
154
+ task=task,
155
155
  )
156
156
 
157
157
  @field_validator("mime_type", mode="before")
@@ -173,21 +173,14 @@ class ResourceTemplate(FastMCPComponent):
173
173
  )
174
174
 
175
175
  async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
176
- """Create a resource from the template with the given parameters."""
177
-
178
- async def resource_read_fn() -> str | bytes:
179
- # Call function and check if result is a coroutine
180
- result = await self.read(arguments=params)
181
- return result
176
+ """Create a resource from the template with the given parameters.
182
177
 
183
- return Resource.from_function(
184
- fn=resource_read_fn,
185
- uri=uri,
186
- name=self.name,
187
- description=self.description,
188
- mime_type=self.mime_type,
189
- tags=self.tags,
190
- enabled=self.enabled,
178
+ The base implementation does not support background tasks.
179
+ Use FunctionResourceTemplate for task support.
180
+ """
181
+ raise NotImplementedError(
182
+ "Subclasses must implement create_resource(). "
183
+ "Use FunctionResourceTemplate for task support."
191
184
  )
192
185
 
193
186
  def to_mcp_template(
@@ -239,45 +232,59 @@ class FunctionResourceTemplate(ResourceTemplate):
239
232
  """A template for dynamically creating resources."""
240
233
 
241
234
  fn: Callable[..., Any]
235
+ task_config: Annotated[
236
+ TaskConfig,
237
+ Field(description="Background task execution configuration (SEP-1686)."),
238
+ ] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))
242
239
 
243
- async def read(self, arguments: dict[str, Any]) -> str | bytes:
244
- """Read the resource content."""
245
- from fastmcp.server.context import Context
240
+ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
241
+ """Create a resource from the template with the given parameters."""
246
242
 
247
- # Add context to parameters if needed
248
- kwargs = arguments.copy()
249
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
250
- if context_kwarg and context_kwarg not in kwargs:
251
- kwargs[context_kwarg] = get_context()
243
+ async def resource_read_fn() -> str | bytes:
244
+ # Call function and check if result is a coroutine
245
+ result = await self.read(arguments=params)
246
+ return result
247
+
248
+ return Resource.from_function(
249
+ fn=resource_read_fn,
250
+ uri=uri,
251
+ name=self.name,
252
+ description=self.description,
253
+ mime_type=self.mime_type,
254
+ tags=self.tags,
255
+ enabled=self.enabled,
256
+ task=self.task_config,
257
+ )
252
258
 
259
+ async def read(self, arguments: dict[str, Any]) -> str | bytes:
260
+ """Read the resource content."""
253
261
  # Type coercion for query parameters (which arrive as strings)
254
- # Get function signature for type hints
262
+ kwargs = arguments.copy()
255
263
  sig = inspect.signature(self.fn)
256
264
  for param_name, param_value in list(kwargs.items()):
257
265
  if param_name in sig.parameters and isinstance(param_value, str):
258
266
  param = sig.parameters[param_name]
259
267
  annotation = param.annotation
260
268
 
261
- # Skip if no annotation or annotation is str
262
269
  if annotation is inspect.Parameter.empty or annotation is str:
263
270
  continue
264
271
 
265
- # Handle common type coercions
266
272
  try:
267
273
  if annotation is int:
268
274
  kwargs[param_name] = int(param_value)
269
275
  elif annotation is float:
270
276
  kwargs[param_name] = float(param_value)
271
277
  elif annotation is bool:
272
- # Handle boolean strings
273
278
  kwargs[param_name] = param_value.lower() in ("true", "1", "yes")
274
279
  except (ValueError, AttributeError):
275
- # Let validate_call handle the error
276
280
  pass
277
281
 
282
+ # self.fn is wrapped by without_injected_parameters which handles
283
+ # dependency resolution internally, so we call it directly
278
284
  result = self.fn(**kwargs)
279
285
  if inspect.isawaitable(result):
280
286
  result = await result
287
+
281
288
  return result
282
289
 
283
290
  @classmethod
@@ -294,9 +301,9 @@ class FunctionResourceTemplate(ResourceTemplate):
294
301
  enabled: bool | None = None,
295
302
  annotations: Annotations | None = None,
296
303
  meta: dict[str, Any] | None = None,
304
+ task: bool | TaskConfig | None = None,
297
305
  ) -> FunctionResourceTemplate:
298
306
  """Create a template from a function."""
299
- from fastmcp.server.context import Context
300
307
 
301
308
  func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
302
309
  if func_name == "<lambda>":
@@ -311,10 +318,6 @@ class FunctionResourceTemplate(ResourceTemplate):
311
318
  "Functions with *args are not supported as resource templates"
312
319
  )
313
320
 
314
- # Auto-detect context parameter if not provided
315
-
316
- context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
317
-
318
321
  # Extract path and query parameters from URI template
319
322
  path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
320
323
  query_params = extract_query_params(uri_template)
@@ -323,24 +326,23 @@ class FunctionResourceTemplate(ResourceTemplate):
323
326
  if not all_uri_params:
324
327
  raise ValueError("URI template must contain at least one parameter")
325
328
 
326
- func_params = set(sig.parameters.keys())
327
- if context_kwarg:
328
- func_params.discard(context_kwarg)
329
+ # Use wrapper to get user-facing parameters (excludes injected params)
330
+ wrapper_fn = without_injected_parameters(fn)
331
+ user_sig = inspect.signature(wrapper_fn)
332
+ func_params = set(user_sig.parameters.keys())
329
333
 
330
334
  # Get required and optional function parameters
331
335
  required_params = {
332
336
  p
333
337
  for p in func_params
334
- if sig.parameters[p].default is inspect.Parameter.empty
335
- and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
336
- and p != context_kwarg
338
+ if user_sig.parameters[p].default is inspect.Parameter.empty
339
+ and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
337
340
  }
338
341
  optional_params = {
339
342
  p
340
343
  for p in func_params
341
- if sig.parameters[p].default is not inspect.Parameter.empty
342
- and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
343
- and p != context_kwarg
344
+ if user_sig.parameters[p].default is not inspect.Parameter.empty
345
+ and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
344
346
  }
345
347
 
346
348
  # Validate RFC 6570 query parameters
@@ -370,6 +372,15 @@ class FunctionResourceTemplate(ResourceTemplate):
370
372
 
371
373
  description = description or inspect.getdoc(fn)
372
374
 
375
+ # Normalize task to TaskConfig and validate
376
+ if task is None:
377
+ task_config = TaskConfig(mode="forbidden")
378
+ elif isinstance(task, bool):
379
+ task_config = TaskConfig.from_bool(task)
380
+ else:
381
+ task_config = task
382
+ task_config.validate_function(fn, func_name)
383
+
373
384
  # if the fn is a callable class, we need to get the __call__ method from here out
374
385
  if not inspect.isroutine(fn):
375
386
  fn = fn.__call__
@@ -377,15 +388,13 @@ class FunctionResourceTemplate(ResourceTemplate):
377
388
  if isinstance(fn, staticmethod):
378
389
  fn = fn.__func__
379
390
 
380
- type_adapter = get_cached_typeadapter(fn)
391
+ wrapper_fn = without_injected_parameters(fn)
392
+ type_adapter = get_cached_typeadapter(wrapper_fn)
381
393
  parameters = type_adapter.json_schema()
394
+ parameters = compress_schema(parameters, prune_titles=True)
382
395
 
383
- # compress the schema
384
- prune_params = [context_kwarg] if context_kwarg else None
385
- parameters = compress_schema(parameters, prune_params=prune_params)
386
-
387
- # ensure the arguments are properly cast
388
- fn = validate_call(fn)
396
+ # Use validate_call on wrapper for runtime type coercion
397
+ fn = validate_call(wrapper_fn)
389
398
 
390
399
  return cls(
391
400
  uri_template=uri_template,
@@ -400,4 +409,5 @@ class FunctionResourceTemplate(ResourceTemplate):
400
409
  enabled=enabled if enabled is not None else True,
401
410
  annotations=annotations,
402
411
  meta=meta,
412
+ task_config=task_config,
403
413
  )
@@ -23,12 +23,3 @@ __all__ = [
23
23
  "StaticTokenVerifier",
24
24
  "TokenVerifier",
25
25
  ]
26
-
27
-
28
- def __getattr__(name: str):
29
- # Defer import because it raises a deprecation warning
30
- if name == "BearerAuthProvider":
31
- from .providers.bearer import BearerAuthProvider
32
-
33
- return BearerAuthProvider
34
- raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -1,9 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from typing import Any, cast
5
+ from urllib.parse import urlparse
4
6
 
7
+ from mcp.server.auth.handlers.token import TokenErrorResponse
8
+ from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
9
+ from mcp.server.auth.json_response import PydanticJSONResponse
5
10
  from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
6
11
  from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
12
+ from mcp.server.auth.middleware.client_auth import ClientAuthenticator
7
13
  from mcp.server.auth.provider import (
8
14
  AccessToken as _SDKAccessToken,
9
15
  )
@@ -16,6 +22,7 @@ from mcp.server.auth.provider import (
16
22
  TokenVerifier as TokenVerifierProtocol,
17
23
  )
18
24
  from mcp.server.auth.routes import (
25
+ cors_middleware,
19
26
  create_auth_routes,
20
27
  create_protected_resource_routes,
21
28
  )
@@ -39,6 +46,48 @@ class AccessToken(_SDKAccessToken):
39
46
  claims: dict[str, Any] = Field(default_factory=dict)
40
47
 
41
48
 
49
+ class TokenHandler(_SDKTokenHandler):
50
+ """TokenHandler that returns OAuth 2.1 compliant error responses.
51
+
52
+ The MCP SDK returns `unauthorized_client` for client authentication failures.
53
+ However, per RFC 6749 Section 5.2, authentication failures should return
54
+ `invalid_client` with HTTP 401, not `unauthorized_client`.
55
+
56
+ This distinction matters: `unauthorized_client` means "client exists but
57
+ can't do this", while `invalid_client` means "client doesn't exist or
58
+ credentials are wrong". Claude's OAuth client uses this to decide whether
59
+ to re-register.
60
+
61
+ This handler transforms 401 responses with `unauthorized_client` to use
62
+ `invalid_client` instead, making the error semantics correct per OAuth spec.
63
+ """
64
+
65
+ async def handle(self, request: Any):
66
+ """Wrap SDK handle() and transform auth error responses."""
67
+ response = await super().handle(request)
68
+
69
+ # Transform 401 unauthorized_client -> invalid_client
70
+ if response.status_code == 401:
71
+ try:
72
+ body = json.loads(response.body)
73
+ if body.get("error") == "unauthorized_client":
74
+ return PydanticJSONResponse(
75
+ content=TokenErrorResponse(
76
+ error="invalid_client",
77
+ error_description=body.get("error_description"),
78
+ ),
79
+ status_code=401,
80
+ headers={
81
+ "Cache-Control": "no-store",
82
+ "Pragma": "no-cache",
83
+ },
84
+ )
85
+ except (json.JSONDecodeError, AttributeError):
86
+ pass # Not JSON or unexpected format, return as-is
87
+
88
+ return response
89
+
90
+
42
91
  class AuthProvider(TokenVerifierProtocol):
43
92
  """Base class for all FastMCP authentication providers.
44
93
 
@@ -140,12 +189,13 @@ class AuthProvider(TokenVerifierProtocol):
140
189
  Returns:
141
190
  List of Starlette Middleware instances to apply to the HTTP app
142
191
  """
192
+ # TODO(ty): remove type ignores when ty supports Starlette Middleware typing
143
193
  return [
144
194
  Middleware(
145
- AuthenticationMiddleware,
195
+ AuthenticationMiddleware, # type: ignore[arg-type]
146
196
  backend=BearerAuthBackend(self),
147
197
  ),
148
- Middleware(AuthContextMiddleware),
198
+ Middleware(AuthContextMiddleware), # type: ignore[arg-type]
149
199
  ]
150
200
 
151
201
  def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None:
@@ -367,7 +417,7 @@ class OAuthProvider(
367
417
  self.issuer_url is not None
368
418
  ) # typing check (issuer_url defaults to base_url)
369
419
 
370
- oauth_routes = create_auth_routes(
420
+ sdk_routes = create_auth_routes(
371
421
  provider=self,
372
422
  issuer_url=self.base_url,
373
423
  service_documentation_url=self.service_documentation_url,
@@ -375,6 +425,32 @@ class OAuthProvider(
375
425
  revocation_options=self.revocation_options,
376
426
  )
377
427
 
428
+ # Replace the token endpoint with our custom handler that returns
429
+ # proper OAuth 2.1 error codes (invalid_client instead of unauthorized_client)
430
+ oauth_routes: list[Route] = []
431
+ for route in sdk_routes:
432
+ if (
433
+ isinstance(route, Route)
434
+ and route.path == "/token"
435
+ and route.methods is not None
436
+ and "POST" in route.methods
437
+ ):
438
+ # Replace with our OAuth 2.1 compliant token handler
439
+ token_handler = TokenHandler(
440
+ provider=self, client_authenticator=ClientAuthenticator(self)
441
+ )
442
+ oauth_routes.append(
443
+ Route(
444
+ path="/token",
445
+ endpoint=cors_middleware(
446
+ token_handler.handle, ["POST", "OPTIONS"]
447
+ ),
448
+ methods=["POST", "OPTIONS"],
449
+ )
450
+ )
451
+ else:
452
+ oauth_routes.append(route)
453
+
378
454
  # Get the resource URL based on the MCP path
379
455
  resource_url = self._get_resource_url(mcp_path)
380
456
 
@@ -397,3 +473,51 @@ class OAuthProvider(
397
473
  oauth_routes.extend(super().get_routes(mcp_path))
398
474
 
399
475
  return oauth_routes
476
+
477
+ def get_well_known_routes(
478
+ self,
479
+ mcp_path: str | None = None,
480
+ ) -> list[Route]:
481
+ """Get well-known discovery routes with RFC 8414 path-aware support.
482
+
483
+ Overrides the base implementation to support path-aware authorization
484
+ server metadata discovery per RFC 8414. If issuer_url has a path component,
485
+ the authorization server metadata route is adjusted to include that path.
486
+
487
+ For example, if issuer_url is "http://example.com/api", the discovery
488
+ endpoint will be at "/.well-known/oauth-authorization-server/api" instead
489
+ of just "/.well-known/oauth-authorization-server".
490
+
491
+ Args:
492
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
493
+
494
+ Returns:
495
+ List of well-known discovery routes
496
+ """
497
+ routes = super().get_well_known_routes(mcp_path)
498
+
499
+ # RFC 8414: If issuer_url has a path, use path-aware discovery
500
+ if self.issuer_url:
501
+ parsed = urlparse(str(self.issuer_url))
502
+ issuer_path = parsed.path.rstrip("/")
503
+
504
+ if issuer_path and issuer_path != "/":
505
+ # Replace /.well-known/oauth-authorization-server with path-aware version
506
+ new_routes = []
507
+ for route in routes:
508
+ if route.path == "/.well-known/oauth-authorization-server":
509
+ new_path = (
510
+ f"/.well-known/oauth-authorization-server{issuer_path}"
511
+ )
512
+ new_routes.append(
513
+ Route(
514
+ new_path,
515
+ endpoint=route.endpoint,
516
+ methods=route.methods,
517
+ )
518
+ )
519
+ else:
520
+ new_routes.append(route)
521
+ return new_routes
522
+
523
+ return routes