fastmcp 2.12.5__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 (133) hide show
  1. fastmcp/__init__.py +2 -23
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +19 -33
  5. fastmcp/cli/install/claude_code.py +6 -6
  6. fastmcp/cli/install/claude_desktop.py +3 -3
  7. fastmcp/cli/install/cursor.py +18 -12
  8. fastmcp/cli/install/gemini_cli.py +3 -3
  9. fastmcp/cli/install/mcp_json.py +3 -3
  10. fastmcp/cli/install/shared.py +0 -15
  11. fastmcp/cli/run.py +13 -8
  12. fastmcp/cli/tasks.py +110 -0
  13. fastmcp/client/__init__.py +9 -9
  14. fastmcp/client/auth/oauth.py +123 -225
  15. fastmcp/client/client.py +697 -95
  16. fastmcp/client/elicitation.py +11 -5
  17. fastmcp/client/logging.py +18 -14
  18. fastmcp/client/messages.py +7 -5
  19. fastmcp/client/oauth_callback.py +85 -171
  20. fastmcp/client/roots.py +2 -1
  21. fastmcp/client/sampling.py +1 -1
  22. fastmcp/client/tasks.py +614 -0
  23. fastmcp/client/transports.py +117 -30
  24. fastmcp/contrib/component_manager/__init__.py +1 -1
  25. fastmcp/contrib/component_manager/component_manager.py +2 -2
  26. fastmcp/contrib/component_manager/component_service.py +10 -26
  27. fastmcp/contrib/mcp_mixin/README.md +32 -1
  28. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  29. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  30. fastmcp/dependencies.py +25 -0
  31. fastmcp/experimental/sampling/handlers/openai.py +3 -3
  32. fastmcp/experimental/server/openapi/__init__.py +20 -21
  33. fastmcp/experimental/utilities/openapi/__init__.py +16 -47
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +54 -51
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +43 -21
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +161 -61
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -14
  45. fastmcp/server/auth/auth.py +197 -46
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1469 -298
  50. fastmcp/server/auth/oidc_proxy.py +91 -20
  51. fastmcp/server/auth/providers/auth0.py +40 -21
  52. fastmcp/server/auth/providers/aws.py +29 -3
  53. fastmcp/server/auth/providers/azure.py +312 -131
  54. fastmcp/server/auth/providers/debug.py +114 -0
  55. fastmcp/server/auth/providers/descope.py +86 -29
  56. fastmcp/server/auth/providers/discord.py +308 -0
  57. fastmcp/server/auth/providers/github.py +29 -8
  58. fastmcp/server/auth/providers/google.py +48 -9
  59. fastmcp/server/auth/providers/in_memory.py +29 -5
  60. fastmcp/server/auth/providers/introspection.py +281 -0
  61. fastmcp/server/auth/providers/jwt.py +48 -31
  62. fastmcp/server/auth/providers/oci.py +233 -0
  63. fastmcp/server/auth/providers/scalekit.py +238 -0
  64. fastmcp/server/auth/providers/supabase.py +188 -0
  65. fastmcp/server/auth/providers/workos.py +35 -17
  66. fastmcp/server/context.py +236 -116
  67. fastmcp/server/dependencies.py +503 -18
  68. fastmcp/server/elicitation.py +286 -48
  69. fastmcp/server/event_store.py +177 -0
  70. fastmcp/server/http.py +71 -20
  71. fastmcp/server/low_level.py +165 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +50 -39
  76. fastmcp/server/middleware/middleware.py +29 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi/__init__.py +35 -0
  80. fastmcp/{experimental/server → server}/openapi/components.py +15 -10
  81. fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
  82. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  83. fastmcp/server/proxy.py +72 -48
  84. fastmcp/server/server.py +1415 -733
  85. fastmcp/server/tasks/__init__.py +21 -0
  86. fastmcp/server/tasks/capabilities.py +22 -0
  87. fastmcp/server/tasks/config.py +89 -0
  88. fastmcp/server/tasks/converters.py +205 -0
  89. fastmcp/server/tasks/handlers.py +356 -0
  90. fastmcp/server/tasks/keys.py +93 -0
  91. fastmcp/server/tasks/protocol.py +355 -0
  92. fastmcp/server/tasks/subscriptions.py +205 -0
  93. fastmcp/settings.py +125 -113
  94. fastmcp/tools/__init__.py +1 -1
  95. fastmcp/tools/tool.py +138 -55
  96. fastmcp/tools/tool_manager.py +30 -112
  97. fastmcp/tools/tool_transform.py +12 -21
  98. fastmcp/utilities/cli.py +67 -28
  99. fastmcp/utilities/components.py +10 -5
  100. fastmcp/utilities/inspect.py +79 -23
  101. fastmcp/utilities/json_schema.py +4 -4
  102. fastmcp/utilities/json_schema_type.py +8 -8
  103. fastmcp/utilities/logging.py +118 -8
  104. fastmcp/utilities/mcp_config.py +1 -2
  105. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  106. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  107. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  108. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
  109. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  110. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  111. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  112. fastmcp/utilities/openapi/__init__.py +63 -0
  113. fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
  114. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  115. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
  116. fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
  117. fastmcp/utilities/tests.py +92 -5
  118. fastmcp/utilities/types.py +86 -16
  119. fastmcp/utilities/ui.py +626 -0
  120. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
  121. fastmcp-2.14.0.dist-info/RECORD +156 -0
  122. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
  123. fastmcp/cli/claude.py +0 -135
  124. fastmcp/server/auth/providers/bearer.py +0 -25
  125. fastmcp/server/openapi.py +0 -1083
  126. fastmcp/utilities/openapi.py +0 -1568
  127. fastmcp/utilities/storage.py +0 -204
  128. fastmcp-2.12.5.dist-info/RECORD +0 -134
  129. fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  130. fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
  131. fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
  132. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
  133. {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Any, Generic, Literal
4
+ from enum import Enum
5
+ from typing import Any, Generic, Literal, get_origin
5
6
 
6
7
  from mcp.server.elicitation import (
7
8
  CancelledElicitation,
@@ -20,8 +21,11 @@ __all__ = [
20
21
  "AcceptedElicitation",
21
22
  "CancelledElicitation",
22
23
  "DeclinedElicitation",
23
- "get_elicitation_schema",
24
+ "ElicitConfig",
24
25
  "ScalarElicitationType",
26
+ "get_elicitation_schema",
27
+ "handle_elicit_accept",
28
+ "parse_elicit_response_type",
25
29
  ]
26
30
 
27
31
  logger = get_logger(__name__)
@@ -37,50 +41,64 @@ class ElicitationJsonSchema(GenerateJsonSchema):
37
41
  Optionally adds enumNames for better UI display when available.
38
42
  """
39
43
 
40
- def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue:
41
- """Override to prevent ref generation for enums."""
44
+ def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: # type: ignore[override]
45
+ """Override to prevent ref generation for enums and handle list schemas."""
42
46
  # For enum schemas, bypass the ref mechanism entirely
43
47
  if schema["type"] == "enum":
44
48
  # Directly call our custom enum_schema without going through handler
45
49
  # This prevents the ref/defs mechanism from being invoked
46
- return self.enum_schema(schema)
50
+ return self.enum_schema(schema) # type: ignore[arg-type]
51
+ # For list schemas, check if items are enums
52
+ if schema["type"] == "list":
53
+ return self.list_schema(schema) # type: ignore[arg-type]
47
54
  # For all other types, use the default implementation
48
55
  return super().generate_inner(schema)
49
56
 
57
+ def list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue:
58
+ """Generate schema for list types, detecting enum items for multi-select."""
59
+ items_schema = schema.get("items_schema")
60
+
61
+ # Check if items are enum/Literal
62
+ if items_schema and items_schema.get("type") == "enum":
63
+ # Generate array with enum items
64
+ items = self.enum_schema(items_schema) # type: ignore[arg-type]
65
+ # If items have oneOf pattern, convert to anyOf for multi-select per SEP-1330
66
+ if "oneOf" in items:
67
+ items = {"anyOf": items["oneOf"]}
68
+ return {
69
+ "type": "array",
70
+ "items": items, # Will be {"enum": [...]} or {"anyOf": [...]}
71
+ }
72
+
73
+ # Check if items are Literal (which Pydantic represents differently)
74
+ if items_schema:
75
+ # Try to detect Literal patterns
76
+ items_result = super().generate_inner(items_schema)
77
+ # If it's a const pattern or enum-like, allow it
78
+ if (
79
+ "const" in items_result
80
+ or "enum" in items_result
81
+ or "oneOf" in items_result
82
+ ):
83
+ # Convert oneOf to anyOf for multi-select
84
+ if "oneOf" in items_result:
85
+ items_result = {"anyOf": items_result["oneOf"]}
86
+ return {
87
+ "type": "array",
88
+ "items": items_result,
89
+ }
90
+
91
+ # Default behavior for non-enum arrays
92
+ return super().list_schema(schema)
93
+
50
94
  def enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue:
51
- """Generate inline enum schema with optional enumNames for better UI.
95
+ """Generate inline enum schema.
52
96
 
53
- If enum members have a _display_name_ attribute or custom __str__,
54
- we'll include enumNames for better UI representation.
97
+ Always generates enum pattern: {"enum": [value, ...]}
98
+ Titled enums are handled separately via dict-based syntax in ctx.elicit().
55
99
  """
56
- # Get the base schema from parent
57
- result = super().enum_schema(schema)
58
-
59
- # Try to add enumNames if the enum has display-friendly names
60
- enum_cls = schema.get("cls")
61
- if enum_cls:
62
- members = schema.get("members", [])
63
- enum_names = []
64
- has_custom_names = False
65
-
66
- for member in members:
67
- # Check if member has a custom display name attribute
68
- if hasattr(member, "_display_name_"):
69
- enum_names.append(member._display_name_)
70
- has_custom_names = True
71
- # Or use the member name with better formatting
72
- else:
73
- # Convert SNAKE_CASE to Title Case for display
74
- display_name = member.name.replace("_", " ").title()
75
- enum_names.append(display_name)
76
- if display_name != member.value:
77
- has_custom_names = True
78
-
79
- # Only add enumNames if they differ from the values
80
- if has_custom_names:
81
- result["enumNames"] = enum_names
82
-
83
- return result
100
+ # Get the base schema from parent - always use simple enum pattern
101
+ return super().enum_schema(schema)
84
102
 
85
103
 
86
104
  # we can't use the low-level AcceptedElicitation because it only works with BaseModels
@@ -96,6 +114,207 @@ class ScalarElicitationType(Generic[T]):
96
114
  value: T
97
115
 
98
116
 
117
+ @dataclass
118
+ class ElicitConfig:
119
+ """Configuration for an elicitation request.
120
+
121
+ Attributes:
122
+ schema: The JSON schema to send to the client
123
+ response_type: The type to validate responses with (None for raw schemas)
124
+ is_raw: True if schema was built directly (extract "value" from response)
125
+ """
126
+
127
+ schema: dict[str, Any]
128
+ response_type: type | None
129
+ is_raw: bool
130
+
131
+
132
+ def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
133
+ """Parse response_type into schema and handling configuration.
134
+
135
+ Supports multiple syntaxes:
136
+ - None: Empty object schema, expect empty response
137
+ - dict: {"low": {"title": "..."}} -> single-select titled enum
138
+ - list patterns:
139
+ - [["a", "b"]] -> multi-select untitled
140
+ - [{"low": {...}}] -> multi-select titled
141
+ - ["a", "b"] -> single-select untitled
142
+ - list[X] type annotation: multi-select with type
143
+ - Scalar types (bool, int, float, str, Literal, Enum): single value
144
+ - Other types (dataclass, BaseModel): use directly
145
+ """
146
+ if response_type is None:
147
+ return ElicitConfig(
148
+ schema={"type": "object", "properties": {}},
149
+ response_type=None,
150
+ is_raw=False,
151
+ )
152
+
153
+ if isinstance(response_type, dict):
154
+ return _parse_dict_syntax(response_type)
155
+
156
+ if isinstance(response_type, list):
157
+ return _parse_list_syntax(response_type)
158
+
159
+ if get_origin(response_type) is list:
160
+ return _parse_generic_list(response_type)
161
+
162
+ if _is_scalar_type(response_type):
163
+ return _parse_scalar_type(response_type)
164
+
165
+ # Other types (dataclass, BaseModel, etc.) - use directly
166
+ return ElicitConfig(
167
+ schema=get_elicitation_schema(response_type),
168
+ response_type=response_type,
169
+ is_raw=False,
170
+ )
171
+
172
+
173
+ def _is_scalar_type(response_type: Any) -> bool:
174
+ """Check if response_type is a scalar type that needs wrapping."""
175
+ return (
176
+ response_type in {bool, int, float, str}
177
+ or get_origin(response_type) is Literal
178
+ or (isinstance(response_type, type) and issubclass(response_type, Enum))
179
+ )
180
+
181
+
182
+ def _parse_dict_syntax(d: dict[str, Any]) -> ElicitConfig:
183
+ """Parse dict syntax: {"low": {"title": "..."}} -> single-select titled."""
184
+ if not d:
185
+ raise ValueError("Dict response_type cannot be empty.")
186
+ enum_schema = _dict_to_enum_schema(d, multi_select=False)
187
+ return ElicitConfig(
188
+ schema={
189
+ "type": "object",
190
+ "properties": {"value": enum_schema},
191
+ "required": ["value"],
192
+ },
193
+ response_type=None,
194
+ is_raw=True,
195
+ )
196
+
197
+
198
+ def _parse_list_syntax(lst: list[Any]) -> ElicitConfig:
199
+ """Parse list patterns: [[...]], [{...}], or [...]."""
200
+ # [["a", "b", "c"]] -> multi-select untitled
201
+ if (
202
+ len(lst) == 1
203
+ and isinstance(lst[0], list)
204
+ and lst[0]
205
+ and all(isinstance(item, str) for item in lst[0])
206
+ ):
207
+ return ElicitConfig(
208
+ schema={
209
+ "type": "object",
210
+ "properties": {"value": {"type": "array", "items": {"enum": lst[0]}}},
211
+ "required": ["value"],
212
+ },
213
+ response_type=None,
214
+ is_raw=True,
215
+ )
216
+
217
+ # [{"low": {"title": "..."}}] -> multi-select titled
218
+ if len(lst) == 1 and isinstance(lst[0], dict) and lst[0]:
219
+ enum_schema = _dict_to_enum_schema(lst[0], multi_select=True)
220
+ return ElicitConfig(
221
+ schema={
222
+ "type": "object",
223
+ "properties": {"value": {"type": "array", "items": enum_schema}},
224
+ "required": ["value"],
225
+ },
226
+ response_type=None,
227
+ is_raw=True,
228
+ )
229
+
230
+ # ["a", "b", "c"] -> single-select untitled
231
+ if lst and all(isinstance(item, str) for item in lst):
232
+ choice_literal = Literal[tuple(lst)] # type: ignore[valid-type]
233
+ wrapped = ScalarElicitationType[choice_literal] # type: ignore[valid-type]
234
+ return ElicitConfig(
235
+ schema=get_elicitation_schema(wrapped), # type: ignore[arg-type]
236
+ response_type=wrapped, # type: ignore[assignment]
237
+ is_raw=False,
238
+ )
239
+
240
+ raise ValueError(f"Invalid list response_type format. Received: {lst}")
241
+
242
+
243
+ def _parse_generic_list(response_type: Any) -> ElicitConfig:
244
+ """Parse list[X] type annotation -> multi-select."""
245
+ wrapped = ScalarElicitationType[response_type] # type: ignore[valid-type]
246
+ return ElicitConfig(
247
+ schema=get_elicitation_schema(wrapped), # type: ignore[arg-type]
248
+ response_type=wrapped, # type: ignore[assignment]
249
+ is_raw=False,
250
+ )
251
+
252
+
253
+ def _parse_scalar_type(response_type: Any) -> ElicitConfig:
254
+ """Parse scalar types (bool, int, float, str, Literal, Enum)."""
255
+ wrapped = ScalarElicitationType[response_type] # type: ignore[valid-type]
256
+ return ElicitConfig(
257
+ schema=get_elicitation_schema(wrapped), # type: ignore[arg-type]
258
+ response_type=wrapped, # type: ignore[assignment]
259
+ is_raw=False,
260
+ )
261
+
262
+
263
+ def handle_elicit_accept(
264
+ config: ElicitConfig, content: Any
265
+ ) -> AcceptedElicitation[Any]:
266
+ """Handle an accepted elicitation response.
267
+
268
+ Args:
269
+ config: The elicitation configuration from parse_elicit_response_type
270
+ content: The response content from the client
271
+
272
+ Returns:
273
+ AcceptedElicitation with the extracted/validated data
274
+ """
275
+ # For raw schemas (dict/nested-list syntax), extract value directly
276
+ if config.is_raw:
277
+ if not isinstance(content, dict) or "value" not in content:
278
+ raise ValueError("Elicitation response missing required 'value' field.")
279
+ return AcceptedElicitation[Any](data=content["value"])
280
+
281
+ # For typed schemas, validate with Pydantic
282
+ if config.response_type is not None:
283
+ type_adapter = get_cached_typeadapter(config.response_type)
284
+ validated_data = type_adapter.validate_python(content)
285
+ if isinstance(validated_data, ScalarElicitationType):
286
+ return AcceptedElicitation[Any](data=validated_data.value)
287
+ return AcceptedElicitation[Any](data=validated_data)
288
+
289
+ # For None response_type, expect empty response
290
+ if content:
291
+ raise ValueError(
292
+ f"Elicitation expected an empty response, but received: {content}"
293
+ )
294
+ return AcceptedElicitation[dict[str, Any]](data={})
295
+
296
+
297
+ def _dict_to_enum_schema(
298
+ enum_dict: dict[str, dict[str, str]], multi_select: bool = False
299
+ ) -> dict[str, Any]:
300
+ """Convert dict enum to SEP-1330 compliant schema pattern.
301
+
302
+ Args:
303
+ enum_dict: {"low": {"title": "Low Priority"}, "medium": {"title": "Medium Priority"}}
304
+ multi_select: If True, use anyOf pattern; if False, use oneOf pattern
305
+
306
+ Returns:
307
+ {"oneOf": [{"const": "low", "title": "Low Priority"}, ...]} for single-select
308
+ {"anyOf": [{"const": "low", "title": "Low Priority"}, ...]} for multi-select
309
+ """
310
+ pattern_key = "anyOf" if multi_select else "oneOf"
311
+ pattern = []
312
+ for value, metadata in enum_dict.items():
313
+ title = metadata.get("title", value)
314
+ pattern.append({"const": value, "title": title})
315
+ return {pattern_key: pattern}
316
+
317
+
99
318
  def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
100
319
  """Get the schema for an elicitation response.
101
320
 
@@ -197,24 +416,43 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
197
416
  )
198
417
  continue
199
418
 
200
- # Check if it's a primitive type
201
- if prop_type not in ALLOWED_TYPES:
419
+ # Check for arrays before checking primitive types
420
+ if prop_type == "array":
421
+ items_schema = prop_schema.get("items", {})
422
+ if items_schema.get("type") == "object":
423
+ raise TypeError(
424
+ f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. "
425
+ "Elicitation schemas must be flat objects with primitive properties only."
426
+ )
427
+
428
+ # Allow arrays with enum patterns (for multi-select)
429
+ if "enum" in items_schema:
430
+ continue # Allowed: {"type": "array", "items": {"enum": [...]}}
431
+
432
+ # Allow arrays with oneOf/anyOf const patterns (SEP-1330)
433
+ if "oneOf" in items_schema or "anyOf" in items_schema:
434
+ union_schemas = items_schema.get("oneOf", []) + items_schema.get(
435
+ "anyOf", []
436
+ )
437
+ if union_schemas and all("const" in s for s in union_schemas):
438
+ continue # Allowed: {"type": "array", "items": {"anyOf": [{"const": ...}, ...]}}
439
+
440
+ # Reject other array types (e.g., arrays of primitives without enum pattern)
202
441
  raise TypeError(
203
- f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not "
204
- f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
442
+ f"Elicitation schema field '{prop_name}' is an array, but arrays are only allowed "
443
+ "when items are enums (for multi-select). Only enum arrays are supported in elicitation schemas."
205
444
  )
206
445
 
207
- # Check for nested objects or arrays of objects (not allowed)
446
+ # Check for nested objects (not allowed)
208
447
  if prop_type == "object":
209
448
  raise TypeError(
210
449
  f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. "
211
450
  "Elicitation schemas must be flat objects with primitive properties only."
212
451
  )
213
452
 
214
- if prop_type == "array":
215
- items_schema = prop_schema.get("items", {})
216
- if items_schema.get("type") == "object":
217
- raise TypeError(
218
- f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. "
219
- "Elicitation schemas must be flat objects with primitive properties only."
220
- )
453
+ # Check if it's a primitive type
454
+ if prop_type not in ALLOWED_TYPES:
455
+ raise TypeError(
456
+ f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not "
457
+ f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
458
+ )
@@ -0,0 +1,177 @@
1
+ """EventStore implementation backed by AsyncKeyValue.
2
+
3
+ This module provides an EventStore implementation that enables SSE polling/resumability
4
+ for Streamable HTTP transports. Events are stored using the key_value package's
5
+ AsyncKeyValue protocol, allowing users to configure any compatible backend
6
+ (in-memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from uuid import uuid4
12
+
13
+ from key_value.aio.adapters.pydantic import PydanticAdapter
14
+ from key_value.aio.protocols import AsyncKeyValue
15
+ from key_value.aio.stores.memory import MemoryStore
16
+ from mcp.server.streamable_http import EventCallback, EventId, EventMessage, StreamId
17
+ from mcp.server.streamable_http import EventStore as SDKEventStore
18
+ from mcp.types import JSONRPCMessage
19
+ from pydantic import BaseModel
20
+
21
+ from fastmcp.utilities.logging import get_logger
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class EventEntry(BaseModel):
27
+ """Stored event entry."""
28
+
29
+ event_id: str
30
+ stream_id: str
31
+ message: dict | None # JSONRPCMessage serialized to dict
32
+
33
+
34
+ class StreamEventList(BaseModel):
35
+ """List of event IDs for a stream."""
36
+
37
+ event_ids: list[str]
38
+
39
+
40
+ class EventStore(SDKEventStore):
41
+ """EventStore implementation backed by AsyncKeyValue.
42
+
43
+ Enables SSE polling/resumability by storing events that can be replayed
44
+ when clients reconnect. Works with any AsyncKeyValue backend (memory, Redis, etc.)
45
+ following the same pattern as ResponseCachingMiddleware and OAuthProxy.
46
+
47
+ Example:
48
+ ```python
49
+ from fastmcp import FastMCP
50
+ from fastmcp.server.event_store import EventStore
51
+
52
+ # Default in-memory storage
53
+ event_store = EventStore()
54
+
55
+ # Or with a custom backend
56
+ from key_value.aio.stores.redis import RedisStore
57
+ redis_backend = RedisStore(url="redis://localhost")
58
+ event_store = EventStore(storage=redis_backend)
59
+
60
+ mcp = FastMCP("MyServer")
61
+ app = mcp.http_app(event_store=event_store, retry_interval=2000)
62
+ ```
63
+
64
+ Args:
65
+ storage: AsyncKeyValue backend. Defaults to MemoryStore.
66
+ max_events_per_stream: Maximum events to retain per stream. Default 100.
67
+ ttl: Event TTL in seconds. Default 3600 (1 hour). Set to None for no expiration.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ storage: AsyncKeyValue | None = None,
73
+ max_events_per_stream: int = 100,
74
+ ttl: int | None = 3600,
75
+ ):
76
+ self._storage: AsyncKeyValue = storage or MemoryStore()
77
+ self._max_events_per_stream = max_events_per_stream
78
+ self._ttl = ttl
79
+
80
+ # PydanticAdapter for type-safe storage (following OAuth proxy pattern)
81
+ self._event_store: PydanticAdapter[EventEntry] = PydanticAdapter[EventEntry](
82
+ key_value=self._storage,
83
+ pydantic_model=EventEntry,
84
+ default_collection="fastmcp_events",
85
+ )
86
+ self._stream_store: PydanticAdapter[StreamEventList] = PydanticAdapter[
87
+ StreamEventList
88
+ ](
89
+ key_value=self._storage,
90
+ pydantic_model=StreamEventList,
91
+ default_collection="fastmcp_streams",
92
+ )
93
+
94
+ async def store_event(
95
+ self, stream_id: StreamId, message: JSONRPCMessage | None
96
+ ) -> EventId:
97
+ """Store an event and return its ID.
98
+
99
+ Args:
100
+ stream_id: ID of the stream the event belongs to
101
+ message: The JSON-RPC message to store, or None for priming events
102
+
103
+ Returns:
104
+ The generated event ID for the stored event
105
+ """
106
+ event_id = str(uuid4())
107
+
108
+ # Store the event entry
109
+ entry = EventEntry(
110
+ event_id=event_id,
111
+ stream_id=stream_id,
112
+ message=message.model_dump(mode="json") if message else None,
113
+ )
114
+ await self._event_store.put(key=event_id, value=entry, ttl=self._ttl)
115
+
116
+ # Update stream's event list
117
+ stream_data = await self._stream_store.get(key=stream_id)
118
+ event_ids = stream_data.event_ids if stream_data else []
119
+ event_ids.append(event_id)
120
+
121
+ # Trim to max events (delete old events)
122
+ if len(event_ids) > self._max_events_per_stream:
123
+ for old_id in event_ids[: -self._max_events_per_stream]:
124
+ await self._event_store.delete(key=old_id)
125
+ event_ids = event_ids[-self._max_events_per_stream :]
126
+
127
+ await self._stream_store.put(
128
+ key=stream_id,
129
+ value=StreamEventList(event_ids=event_ids),
130
+ ttl=self._ttl,
131
+ )
132
+
133
+ return event_id
134
+
135
+ async def replay_events_after(
136
+ self,
137
+ last_event_id: EventId,
138
+ send_callback: EventCallback,
139
+ ) -> StreamId | None:
140
+ """Replay events that occurred after the specified event ID.
141
+
142
+ Args:
143
+ last_event_id: The ID of the last event the client received
144
+ send_callback: A callback function to send events to the client
145
+
146
+ Returns:
147
+ The stream ID of the replayed events, or None if the event ID was not found
148
+ """
149
+ # Look up the event to find its stream
150
+ entry = await self._event_store.get(key=last_event_id)
151
+ if not entry:
152
+ logger.warning(f"Event ID {last_event_id} not found in store")
153
+ return None
154
+
155
+ stream_id = entry.stream_id
156
+ stream_data = await self._stream_store.get(key=stream_id)
157
+ if not stream_data:
158
+ logger.warning(f"Stream {stream_id} not found in store")
159
+ return None
160
+
161
+ event_ids = stream_data.event_ids
162
+
163
+ # Find events after last_event_id
164
+ try:
165
+ start_idx = event_ids.index(last_event_id) + 1
166
+ except ValueError:
167
+ logger.warning(f"Event ID {last_event_id} not found in stream {stream_id}")
168
+ return None
169
+
170
+ # Replay events after the last one
171
+ for event_id in event_ids[start_idx:]:
172
+ event = await self._event_store.get(key=event_id)
173
+ if event and event.message:
174
+ msg = JSONRPCMessage.model_validate(event.message)
175
+ await send_callback(EventMessage(msg, event.event_id))
176
+
177
+ return stream_id