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.
- fastmcp/__init__.py +0 -21
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +8 -22
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/auth/oauth.py +9 -9
- fastmcp/client/client.py +665 -129
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +37 -5
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +1 -1
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +33 -33
- fastmcp/resources/resource.py +29 -12
- fastmcp/resources/template.py +64 -54
- fastmcp/server/auth/__init__.py +0 -9
- fastmcp/server/auth/auth.py +127 -3
- fastmcp/server/auth/oauth_proxy.py +47 -97
- fastmcp/server/auth/oidc_proxy.py +7 -0
- fastmcp/server/auth/providers/in_memory.py +2 -2
- fastmcp/server/auth/providers/oci.py +2 -2
- fastmcp/server/context.py +66 -72
- fastmcp/server/dependencies.py +464 -6
- fastmcp/server/elicitation.py +285 -47
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +15 -3
- fastmcp/server/low_level.py +56 -12
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +4 -3
- fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +50 -37
- fastmcp/server/server.py +731 -532
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +205 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +101 -103
- fastmcp/tools/tool.py +80 -44
- fastmcp/tools/tool_transform.py +1 -12
- fastmcp/utilities/components.py +3 -3
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
- fastmcp/utilities/tests.py +11 -5
- fastmcp/utilities/types.py +8 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/METADATA +5 -4
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/RECORD +71 -59
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- fastmcp/utilities/openapi.py +0 -1568
- /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/elicitation.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from
|
|
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",
|
|
24
|
+
"ElicitConfig",
|
|
23
25
|
"ScalarElicitationType",
|
|
24
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
|
|
95
|
+
"""Generate inline enum schema.
|
|
52
96
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
if prop_type
|
|
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}'
|
|
204
|
-
|
|
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
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
fastmcp/server/http.py
CHANGED
|
@@ -21,6 +21,7 @@ from starlette.types import Lifespan, Receive, Scope, Send
|
|
|
21
21
|
|
|
22
22
|
from fastmcp.server.auth import AuthProvider
|
|
23
23
|
from fastmcp.server.auth.middleware import RequireAuthMiddleware
|
|
24
|
+
from fastmcp.server.tasks.capabilities import get_task_capabilities
|
|
24
25
|
from fastmcp.utilities.logging import get_logger
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
@@ -116,7 +117,8 @@ def create_base_app(
|
|
|
116
117
|
A Starlette application
|
|
117
118
|
"""
|
|
118
119
|
# Always add RequestContextMiddleware as the outermost middleware
|
|
119
|
-
|
|
120
|
+
# TODO(ty): remove type ignore when ty supports Starlette Middleware typing
|
|
121
|
+
middleware.insert(0, Middleware(RequestContextMiddleware)) # type: ignore[arg-type]
|
|
120
122
|
|
|
121
123
|
return StarletteWithLifespan(
|
|
122
124
|
routes=routes,
|
|
@@ -158,10 +160,15 @@ def create_sse_app(
|
|
|
158
160
|
# Create handler for SSE connections
|
|
159
161
|
async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response:
|
|
160
162
|
async with sse.connect_sse(scope, receive, send) as streams:
|
|
163
|
+
# Build experimental capabilities
|
|
164
|
+
experimental_capabilities = get_task_capabilities()
|
|
165
|
+
|
|
161
166
|
await server._mcp_server.run(
|
|
162
167
|
streams[0],
|
|
163
168
|
streams[1],
|
|
164
|
-
server._mcp_server.create_initialization_options(
|
|
169
|
+
server._mcp_server.create_initialization_options(
|
|
170
|
+
experimental_capabilities=experimental_capabilities
|
|
171
|
+
),
|
|
165
172
|
)
|
|
166
173
|
return Response()
|
|
167
174
|
|
|
@@ -256,6 +263,7 @@ def create_streamable_http_app(
|
|
|
256
263
|
server: FastMCP[LifespanResultT],
|
|
257
264
|
streamable_http_path: str,
|
|
258
265
|
event_store: EventStore | None = None,
|
|
266
|
+
retry_interval: int | None = None,
|
|
259
267
|
auth: AuthProvider | None = None,
|
|
260
268
|
json_response: bool = False,
|
|
261
269
|
stateless_http: bool = False,
|
|
@@ -268,7 +276,10 @@ def create_streamable_http_app(
|
|
|
268
276
|
Args:
|
|
269
277
|
server: The FastMCP server instance
|
|
270
278
|
streamable_http_path: Path for StreamableHTTP connections
|
|
271
|
-
event_store: Optional event store for
|
|
279
|
+
event_store: Optional event store for SSE polling/resumability
|
|
280
|
+
retry_interval: Optional retry interval in milliseconds for SSE polling.
|
|
281
|
+
Controls how quickly clients should reconnect after server-initiated
|
|
282
|
+
disconnections. Requires event_store to be set. Defaults to SDK default.
|
|
272
283
|
auth: Optional authentication provider (AuthProvider)
|
|
273
284
|
json_response: Whether to use JSON response format
|
|
274
285
|
stateless_http: Whether to use stateless mode (new transport per request)
|
|
@@ -286,6 +297,7 @@ def create_streamable_http_app(
|
|
|
286
297
|
session_manager = StreamableHTTPSessionManager(
|
|
287
298
|
app=server._mcp_server,
|
|
288
299
|
event_store=event_store,
|
|
300
|
+
retry_interval=retry_interval,
|
|
289
301
|
json_response=json_response,
|
|
290
302
|
stateless=stateless_http,
|
|
291
303
|
)
|