fastmcp 2.3.3__py3-none-any.whl → 2.3.5__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/cli/cli.py +30 -138
- fastmcp/cli/run.py +179 -0
- fastmcp/client/__init__.py +2 -0
- fastmcp/client/client.py +131 -24
- fastmcp/client/logging.py +8 -0
- fastmcp/client/progress.py +38 -0
- fastmcp/client/transports.py +80 -64
- fastmcp/exceptions.py +2 -0
- fastmcp/prompts/prompt.py +12 -6
- fastmcp/resources/resource_manager.py +22 -1
- fastmcp/resources/template.py +21 -17
- fastmcp/resources/types.py +25 -27
- fastmcp/server/context.py +6 -3
- fastmcp/server/http.py +47 -14
- fastmcp/server/openapi.py +14 -1
- fastmcp/server/proxy.py +4 -4
- fastmcp/server/server.py +159 -96
- fastmcp/settings.py +55 -29
- fastmcp/tools/tool.py +45 -45
- fastmcp/tools/tool_manager.py +27 -2
- fastmcp/utilities/exceptions.py +49 -0
- fastmcp/utilities/json_schema.py +78 -17
- fastmcp/utilities/logging.py +11 -6
- fastmcp/utilities/openapi.py +122 -7
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.5.dist-info}/METADATA +3 -3
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.5.dist-info}/RECORD +29 -27
- fastmcp/low_level/sse_server_transport.py +0 -104
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.5.dist-info}/WHEEL +0 -0
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.5.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.3.3.dist-info → fastmcp-2.3.5.dist-info}/licenses/LICENSE +0 -0
fastmcp/tools/tool.py
CHANGED
|
@@ -11,9 +11,8 @@ from mcp.types import Tool as MCPTool
|
|
|
11
11
|
from pydantic import BaseModel, BeforeValidator, Field
|
|
12
12
|
|
|
13
13
|
import fastmcp
|
|
14
|
-
from fastmcp.exceptions import ToolError
|
|
15
14
|
from fastmcp.server.dependencies import get_context
|
|
16
|
-
from fastmcp.utilities.json_schema import
|
|
15
|
+
from fastmcp.utilities.json_schema import compress_schema
|
|
17
16
|
from fastmcp.utilities.logging import get_logger
|
|
18
17
|
from fastmcp.utilities.types import (
|
|
19
18
|
Image,
|
|
@@ -82,7 +81,11 @@ class Tool(BaseModel):
|
|
|
82
81
|
|
|
83
82
|
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
|
|
84
83
|
if context_kwarg:
|
|
85
|
-
|
|
84
|
+
prune_params = [context_kwarg]
|
|
85
|
+
else:
|
|
86
|
+
prune_params = None
|
|
87
|
+
|
|
88
|
+
schema = compress_schema(schema, prune_params=prune_params)
|
|
86
89
|
|
|
87
90
|
return cls(
|
|
88
91
|
fn=fn,
|
|
@@ -102,48 +105,45 @@ class Tool(BaseModel):
|
|
|
102
105
|
|
|
103
106
|
arguments = arguments.copy()
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return _convert_to_content(result, serializer=self.serializer)
|
|
145
|
-
except Exception as e:
|
|
146
|
-
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
|
108
|
+
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
109
|
+
if context_kwarg and context_kwarg not in arguments:
|
|
110
|
+
arguments[context_kwarg] = get_context()
|
|
111
|
+
|
|
112
|
+
if fastmcp.settings.settings.tool_attempt_parse_json_args:
|
|
113
|
+
# Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
|
|
114
|
+
# being passed in as JSON inside a string rather than an actual list.
|
|
115
|
+
#
|
|
116
|
+
# Claude desktop is prone to this - in fact it seems incapable of NOT doing
|
|
117
|
+
# this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
|
|
118
|
+
# which can be pre-parsed here.
|
|
119
|
+
signature = inspect.signature(self.fn)
|
|
120
|
+
for param_name in self.parameters["properties"]:
|
|
121
|
+
arg = arguments.get(param_name, None)
|
|
122
|
+
# if not in signature, we won't have annotations, so skip logic
|
|
123
|
+
if param_name not in signature.parameters:
|
|
124
|
+
continue
|
|
125
|
+
# if not a string, we won't have a JSON to parse, so skip logic
|
|
126
|
+
if not isinstance(arg, str):
|
|
127
|
+
continue
|
|
128
|
+
# skip if the type is a simple type (int, float, bool)
|
|
129
|
+
if signature.parameters[param_name].annotation in (
|
|
130
|
+
int,
|
|
131
|
+
float,
|
|
132
|
+
bool,
|
|
133
|
+
):
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
arguments[param_name] = json.loads(arg)
|
|
137
|
+
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
type_adapter = get_cached_typeadapter(self.fn)
|
|
142
|
+
result = type_adapter.validate_python(arguments)
|
|
143
|
+
if inspect.isawaitable(result):
|
|
144
|
+
result = await result
|
|
145
|
+
|
|
146
|
+
return _convert_to_content(result, serializer=self.serializer)
|
|
147
147
|
|
|
148
148
|
def to_mcp_tool(self, **overrides: Any) -> MCPTool:
|
|
149
149
|
kwargs = {
|
fastmcp/tools/tool_manager.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
5
5
|
|
|
6
6
|
from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
|
|
7
7
|
|
|
8
|
-
from fastmcp.exceptions import NotFoundError
|
|
8
|
+
from fastmcp.exceptions import NotFoundError, ToolError
|
|
9
9
|
from fastmcp.settings import DuplicateBehavior
|
|
10
10
|
from fastmcp.tools.tool import Tool
|
|
11
11
|
from fastmcp.utilities.logging import get_logger
|
|
@@ -94,6 +94,20 @@ class ToolManager:
|
|
|
94
94
|
self._tools[key] = tool
|
|
95
95
|
return tool
|
|
96
96
|
|
|
97
|
+
def remove_tool(self, key: str) -> None:
|
|
98
|
+
"""Remove a tool from the server.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
key: The key of the tool to remove
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
NotFoundError: If the tool is not found
|
|
105
|
+
"""
|
|
106
|
+
if key in self._tools:
|
|
107
|
+
del self._tools[key]
|
|
108
|
+
else:
|
|
109
|
+
raise NotFoundError(f"Unknown tool: {key}")
|
|
110
|
+
|
|
97
111
|
async def call_tool(
|
|
98
112
|
self, key: str, arguments: dict[str, Any]
|
|
99
113
|
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
@@ -102,4 +116,15 @@ class ToolManager:
|
|
|
102
116
|
if not tool:
|
|
103
117
|
raise NotFoundError(f"Unknown tool: {key}")
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
try:
|
|
120
|
+
return await tool.run(arguments)
|
|
121
|
+
|
|
122
|
+
# raise ToolErrors as-is
|
|
123
|
+
except ToolError as e:
|
|
124
|
+
logger.exception(f"Error calling tool {key!r}: {e}")
|
|
125
|
+
raise e
|
|
126
|
+
|
|
127
|
+
# raise other exceptions as ToolErrors without revealing internal details
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.exception(f"Error calling tool {key!r}: {e}")
|
|
130
|
+
raise ToolError(f"Error calling tool {key!r}") from e
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import mcp.types
|
|
6
|
+
from exceptiongroup import BaseExceptionGroup
|
|
7
|
+
from mcp import McpError
|
|
8
|
+
|
|
9
|
+
import fastmcp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def iter_exc(group: BaseExceptionGroup):
|
|
13
|
+
for exc in group.exceptions:
|
|
14
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
15
|
+
yield from iter_exc(exc)
|
|
16
|
+
else:
|
|
17
|
+
yield exc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _exception_handler(group: BaseExceptionGroup):
|
|
21
|
+
for leaf in iter_exc(group):
|
|
22
|
+
if isinstance(leaf, httpx.ConnectTimeout):
|
|
23
|
+
raise McpError(
|
|
24
|
+
error=mcp.types.ErrorData(
|
|
25
|
+
code=httpx.codes.REQUEST_TIMEOUT,
|
|
26
|
+
message="Timed out while waiting for response.",
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
raise leaf
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# this catch handler is used to catch taskgroup exception groups and raise the
|
|
33
|
+
# first exception. This allows more sane debugging.
|
|
34
|
+
_catch_handlers: Mapping[
|
|
35
|
+
type[BaseException] | Iterable[type[BaseException]],
|
|
36
|
+
Callable[[BaseExceptionGroup[Any]], Any],
|
|
37
|
+
] = {
|
|
38
|
+
Exception: _exception_handler,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_catch_handlers() -> Mapping[
|
|
43
|
+
type[BaseException] | Iterable[type[BaseException]],
|
|
44
|
+
Callable[[BaseExceptionGroup[Any]], Any],
|
|
45
|
+
]:
|
|
46
|
+
if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
|
|
47
|
+
return _catch_handlers
|
|
48
|
+
else:
|
|
49
|
+
return {}
|
fastmcp/utilities/json_schema.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
|
-
from collections.abc import Mapping, Sequence
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
def _prune_param(schema: dict, param: str) -> dict:
|
|
@@ -14,6 +13,7 @@ def _prune_param(schema: dict, param: str) -> dict:
|
|
|
14
13
|
removed = props.pop(param, None)
|
|
15
14
|
if removed is None: # nothing to do
|
|
16
15
|
return schema
|
|
16
|
+
|
|
17
17
|
# Keep empty properties object rather than removing it entirely
|
|
18
18
|
schema["properties"] = props
|
|
19
19
|
if param in schema.get("required", []):
|
|
@@ -21,39 +21,100 @@ def _prune_param(schema: dict, param: str) -> dict:
|
|
|
21
21
|
if not schema["required"]:
|
|
22
22
|
schema.pop("required")
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
return schema
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _walk_and_prune(
|
|
28
|
+
schema: dict,
|
|
29
|
+
prune_defs: bool = False,
|
|
30
|
+
prune_titles: bool = False,
|
|
31
|
+
prune_additional_properties: bool = False,
|
|
32
|
+
) -> dict:
|
|
33
|
+
"""Walk the schema and optionally prune titles, unused definitions, and additionalProperties: false."""
|
|
34
|
+
|
|
35
|
+
# Will only be used if prune_defs is True
|
|
25
36
|
used_defs: set[str] = set()
|
|
26
37
|
|
|
27
|
-
def walk(node: object) -> None:
|
|
28
|
-
if isinstance(node,
|
|
29
|
-
|
|
30
|
-
if
|
|
31
|
-
|
|
38
|
+
def walk(node: object) -> None:
|
|
39
|
+
if isinstance(node, dict):
|
|
40
|
+
# Process $ref for definition tracking
|
|
41
|
+
if prune_defs:
|
|
42
|
+
ref = node.get("$ref")
|
|
43
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
44
|
+
used_defs.add(ref.split("/")[-1])
|
|
45
|
+
|
|
46
|
+
# Remove title if requested
|
|
47
|
+
if prune_titles and "title" in node:
|
|
48
|
+
node.pop("title")
|
|
49
|
+
|
|
50
|
+
# Remove additionalProperties: false at any level if requested
|
|
51
|
+
if (
|
|
52
|
+
prune_additional_properties
|
|
53
|
+
and node.get("additionalProperties", None) is False
|
|
54
|
+
):
|
|
55
|
+
node.pop("additionalProperties")
|
|
56
|
+
|
|
57
|
+
# Walk children
|
|
32
58
|
for v in node.values():
|
|
33
59
|
walk(v)
|
|
34
|
-
|
|
60
|
+
|
|
61
|
+
elif isinstance(node, list):
|
|
35
62
|
for v in node:
|
|
36
63
|
walk(v)
|
|
37
64
|
|
|
65
|
+
# Traverse the schema once
|
|
38
66
|
walk(schema)
|
|
39
67
|
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
# Remove orphaned definitions if requested
|
|
69
|
+
if prune_defs:
|
|
70
|
+
defs = schema.get("$defs", {})
|
|
71
|
+
for def_name in list(defs):
|
|
72
|
+
if def_name not in used_defs:
|
|
73
|
+
defs.pop(def_name)
|
|
74
|
+
if not defs:
|
|
75
|
+
schema.pop("$defs", None)
|
|
76
|
+
|
|
77
|
+
return schema
|
|
78
|
+
|
|
47
79
|
|
|
80
|
+
def _prune_additional_properties(schema: dict) -> dict:
|
|
81
|
+
"""Remove additionalProperties from the schema if it is False."""
|
|
82
|
+
if schema.get("additionalProperties", None) is False:
|
|
83
|
+
schema.pop("additionalProperties")
|
|
48
84
|
return schema
|
|
49
85
|
|
|
50
86
|
|
|
51
|
-
def
|
|
87
|
+
def compress_schema(
|
|
88
|
+
schema: dict,
|
|
89
|
+
prune_params: list[str] | None = None,
|
|
90
|
+
prune_defs: bool = True,
|
|
91
|
+
prune_additional_properties: bool = True,
|
|
92
|
+
prune_titles: bool = False,
|
|
93
|
+
) -> dict:
|
|
52
94
|
"""
|
|
53
95
|
Remove the given parameters from the schema.
|
|
54
96
|
|
|
97
|
+
Args:
|
|
98
|
+
schema: The schema to compress
|
|
99
|
+
prune_params: List of parameter names to remove from properties
|
|
100
|
+
prune_defs: Whether to remove unused definitions
|
|
101
|
+
prune_additional_properties: Whether to remove additionalProperties: false
|
|
102
|
+
prune_titles: Whether to remove title fields from the schema
|
|
55
103
|
"""
|
|
104
|
+
# Make a copy so we don't modify the original
|
|
56
105
|
schema = copy.deepcopy(schema)
|
|
57
|
-
|
|
106
|
+
|
|
107
|
+
# Remove specific parameters if requested
|
|
108
|
+
for param in prune_params or []:
|
|
58
109
|
schema = _prune_param(schema, param=param)
|
|
110
|
+
|
|
111
|
+
# Do a single walk to handle pruning operations
|
|
112
|
+
if prune_defs or prune_titles or prune_additional_properties:
|
|
113
|
+
schema = _walk_and_prune(
|
|
114
|
+
schema,
|
|
115
|
+
prune_defs=prune_defs,
|
|
116
|
+
prune_titles=prune_titles,
|
|
117
|
+
prune_additional_properties=prune_additional_properties,
|
|
118
|
+
)
|
|
119
|
+
|
|
59
120
|
return schema
|
fastmcp/utilities/logging.py
CHANGED
|
@@ -21,22 +21,27 @@ def get_logger(name: str) -> logging.Logger:
|
|
|
21
21
|
|
|
22
22
|
def configure_logging(
|
|
23
23
|
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
|
|
24
|
+
logger: logging.Logger | None = None,
|
|
24
25
|
) -> None:
|
|
25
|
-
"""
|
|
26
|
+
"""
|
|
27
|
+
Configure logging for FastMCP.
|
|
26
28
|
|
|
27
29
|
Args:
|
|
30
|
+
logger: the logger to configure
|
|
28
31
|
level: the log level to use
|
|
29
32
|
"""
|
|
33
|
+
if logger is None:
|
|
34
|
+
logger = logging.getLogger("FastMCP")
|
|
35
|
+
|
|
30
36
|
# Only configure the FastMCP logger namespace
|
|
31
37
|
handler = RichHandler(console=Console(stderr=True), rich_tracebacks=True)
|
|
32
38
|
formatter = logging.Formatter("%(message)s")
|
|
33
39
|
handler.setFormatter(formatter)
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
fastmcp_logger.setLevel(level)
|
|
41
|
+
logger.setLevel(level)
|
|
37
42
|
|
|
38
43
|
# Remove any existing handlers to avoid duplicates on reconfiguration
|
|
39
|
-
for hdlr in
|
|
40
|
-
|
|
44
|
+
for hdlr in logger.handlers[:]:
|
|
45
|
+
logger.removeHandler(hdlr)
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
logger.addHandler(handler)
|
fastmcp/utilities/openapi.py
CHANGED
|
@@ -84,6 +84,9 @@ class HTTPRoute(BaseModel):
|
|
|
84
84
|
responses: dict[str, ResponseInfo] = Field(
|
|
85
85
|
default_factory=dict
|
|
86
86
|
) # Key: status code str
|
|
87
|
+
schema_definitions: dict[str, JsonSchema] = Field(
|
|
88
|
+
default_factory=dict
|
|
89
|
+
) # Store component schemas
|
|
87
90
|
|
|
88
91
|
|
|
89
92
|
# Export public symbols
|
|
@@ -221,6 +224,27 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
|
221
224
|
logger.warning("OpenAPI schema has no paths defined.")
|
|
222
225
|
return []
|
|
223
226
|
|
|
227
|
+
# Extract component schemas to add to each route
|
|
228
|
+
schema_definitions = {}
|
|
229
|
+
if hasattr(self.openapi, "components") and self.openapi.components:
|
|
230
|
+
components = self.openapi.components
|
|
231
|
+
if hasattr(components, "schemas") and components.schemas:
|
|
232
|
+
for name, schema in components.schemas.items():
|
|
233
|
+
try:
|
|
234
|
+
if isinstance(schema, Reference):
|
|
235
|
+
resolved_schema = self._resolve_ref(schema)
|
|
236
|
+
schema_definitions[name] = self._extract_schema_as_dict(
|
|
237
|
+
resolved_schema
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
schema_definitions[name] = self._extract_schema_as_dict(
|
|
241
|
+
schema
|
|
242
|
+
)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.warning(
|
|
245
|
+
f"Failed to extract schema definition '{name}': {e}"
|
|
246
|
+
)
|
|
247
|
+
|
|
224
248
|
for path_str, path_item_obj in self.openapi.paths.items():
|
|
225
249
|
if not isinstance(path_item_obj, PathItem):
|
|
226
250
|
logger.warning(
|
|
@@ -269,6 +293,7 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
|
269
293
|
parameters=parameters,
|
|
270
294
|
request_body=request_body_info,
|
|
271
295
|
responses=responses,
|
|
296
|
+
schema_definitions=schema_definitions,
|
|
272
297
|
)
|
|
273
298
|
routes.append(route)
|
|
274
299
|
logger.info(
|
|
@@ -386,16 +411,36 @@ class OpenAPI31Parser(BaseOpenAPIParser):
|
|
|
386
411
|
|
|
387
412
|
param_schema_dict = {}
|
|
388
413
|
if param_schema_obj: # Check if schema exists
|
|
414
|
+
# Resolve the schema if it's a reference
|
|
415
|
+
resolved_schema = self._resolve_ref(param_schema_obj)
|
|
389
416
|
param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
|
|
417
|
+
|
|
418
|
+
# Ensure default value is preserved from resolved schema
|
|
419
|
+
if (
|
|
420
|
+
not isinstance(resolved_schema, Reference)
|
|
421
|
+
and hasattr(resolved_schema, "default")
|
|
422
|
+
and resolved_schema.default is not None
|
|
423
|
+
):
|
|
424
|
+
param_schema_dict["default"] = resolved_schema.default
|
|
390
425
|
elif parameter.content:
|
|
391
426
|
# Handle complex parameters with 'content'
|
|
392
427
|
first_media_type = next(iter(parameter.content.values()), None)
|
|
393
428
|
if (
|
|
394
429
|
first_media_type and first_media_type.media_type_schema
|
|
395
430
|
): # CORRECTED: Use 'media_type_schema'
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
)
|
|
431
|
+
# Resolve the schema if it's a reference
|
|
432
|
+
media_schema = first_media_type.media_type_schema
|
|
433
|
+
resolved_media_schema = self._resolve_ref(media_schema)
|
|
434
|
+
param_schema_dict = self._extract_schema_as_dict(media_schema)
|
|
435
|
+
|
|
436
|
+
# Ensure default value is preserved from resolved schema
|
|
437
|
+
if (
|
|
438
|
+
not isinstance(resolved_media_schema, Reference)
|
|
439
|
+
and hasattr(resolved_media_schema, "default")
|
|
440
|
+
and resolved_media_schema.default is not None
|
|
441
|
+
):
|
|
442
|
+
param_schema_dict["default"] = resolved_media_schema.default
|
|
443
|
+
|
|
399
444
|
logger.debug(
|
|
400
445
|
f"Parameter '{parameter.name}' using schema from 'content' field."
|
|
401
446
|
)
|
|
@@ -543,6 +588,27 @@ class OpenAPI30Parser(BaseOpenAPIParser):
|
|
|
543
588
|
logger.warning("OpenAPI schema has no paths defined.")
|
|
544
589
|
return []
|
|
545
590
|
|
|
591
|
+
# Extract component schemas to add to each route
|
|
592
|
+
schema_definitions = {}
|
|
593
|
+
if hasattr(self.openapi, "components") and self.openapi.components:
|
|
594
|
+
components = self.openapi.components
|
|
595
|
+
if hasattr(components, "schemas") and components.schemas:
|
|
596
|
+
for name, schema in components.schemas.items():
|
|
597
|
+
try:
|
|
598
|
+
if isinstance(schema, Reference_30):
|
|
599
|
+
resolved_schema = self._resolve_ref(schema)
|
|
600
|
+
schema_definitions[name] = self._extract_schema_as_dict(
|
|
601
|
+
resolved_schema
|
|
602
|
+
)
|
|
603
|
+
else:
|
|
604
|
+
schema_definitions[name] = self._extract_schema_as_dict(
|
|
605
|
+
schema
|
|
606
|
+
)
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.warning(
|
|
609
|
+
f"Failed to extract schema definition '{name}': {e}"
|
|
610
|
+
)
|
|
611
|
+
|
|
546
612
|
for path_str, path_item_obj in self.openapi.paths.items():
|
|
547
613
|
if not isinstance(path_item_obj, PathItem_30):
|
|
548
614
|
logger.warning(
|
|
@@ -593,6 +659,7 @@ class OpenAPI30Parser(BaseOpenAPIParser):
|
|
|
593
659
|
parameters=parameters,
|
|
594
660
|
request_body=request_body_info,
|
|
595
661
|
responses=responses,
|
|
662
|
+
schema_definitions=schema_definitions,
|
|
596
663
|
)
|
|
597
664
|
routes.append(route)
|
|
598
665
|
logger.info(
|
|
@@ -711,14 +778,34 @@ class OpenAPI30Parser(BaseOpenAPIParser):
|
|
|
711
778
|
|
|
712
779
|
param_schema_dict = {}
|
|
713
780
|
if param_schema_obj: # Check if schema exists
|
|
781
|
+
# Resolve the schema if it's a reference
|
|
782
|
+
resolved_schema = self._resolve_ref(param_schema_obj)
|
|
714
783
|
param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
|
|
784
|
+
|
|
785
|
+
# Ensure default value is preserved from resolved schema
|
|
786
|
+
if (
|
|
787
|
+
not isinstance(resolved_schema, Reference_30)
|
|
788
|
+
and hasattr(resolved_schema, "default")
|
|
789
|
+
and resolved_schema.default is not None
|
|
790
|
+
):
|
|
791
|
+
param_schema_dict["default"] = resolved_schema.default
|
|
715
792
|
elif parameter.content:
|
|
716
793
|
# Handle complex parameters with 'content'
|
|
717
794
|
first_media_type = next(iter(parameter.content.values()), None)
|
|
718
795
|
if first_media_type and first_media_type.media_type_schema:
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
)
|
|
796
|
+
# Resolve the schema if it's a reference
|
|
797
|
+
media_schema = first_media_type.media_type_schema
|
|
798
|
+
resolved_media_schema = self._resolve_ref(media_schema)
|
|
799
|
+
param_schema_dict = self._extract_schema_as_dict(media_schema)
|
|
800
|
+
|
|
801
|
+
# Ensure default value is preserved from resolved schema
|
|
802
|
+
if (
|
|
803
|
+
not isinstance(resolved_media_schema, Reference_30)
|
|
804
|
+
and hasattr(resolved_media_schema, "default")
|
|
805
|
+
and resolved_media_schema.default is not None
|
|
806
|
+
):
|
|
807
|
+
param_schema_dict["default"] = resolved_media_schema.default
|
|
808
|
+
|
|
722
809
|
logger.debug(
|
|
723
810
|
f"Parameter '{parameter.name}' using schema from 'content' field."
|
|
724
811
|
)
|
|
@@ -1173,6 +1260,23 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
|
|
|
1173
1260
|
# Copy the schema and add description if available
|
|
1174
1261
|
param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {}
|
|
1175
1262
|
|
|
1263
|
+
# Convert #/components/schemas references to #/$defs references
|
|
1264
|
+
if isinstance(param_schema, dict) and "$ref" in param_schema:
|
|
1265
|
+
ref_path = param_schema["$ref"]
|
|
1266
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
1267
|
+
schema_name = ref_path.split("/")[-1]
|
|
1268
|
+
param_schema["$ref"] = f"#/$defs/{schema_name}"
|
|
1269
|
+
|
|
1270
|
+
# Also handle anyOf, allOf, oneOf references
|
|
1271
|
+
for section in ["anyOf", "allOf", "oneOf"]:
|
|
1272
|
+
if section in param_schema and isinstance(param_schema[section], list):
|
|
1273
|
+
for i, item in enumerate(param_schema[section]):
|
|
1274
|
+
if isinstance(item, dict) and "$ref" in item:
|
|
1275
|
+
ref_path = item["$ref"]
|
|
1276
|
+
if ref_path.startswith("#/components/schemas/"):
|
|
1277
|
+
schema_name = ref_path.split("/")[-1]
|
|
1278
|
+
param_schema[section][i]["$ref"] = f"#/$defs/{schema_name}"
|
|
1279
|
+
|
|
1176
1280
|
# Add parameter description to schema if available and not already present
|
|
1177
1281
|
if param.description and not param_schema.get("description"):
|
|
1178
1282
|
param_schema["description"] = param.description
|
|
@@ -1193,8 +1297,19 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
|
|
|
1193
1297
|
if route.request_body.required:
|
|
1194
1298
|
required.extend(body_schema.get("required", []))
|
|
1195
1299
|
|
|
1196
|
-
|
|
1300
|
+
result = {
|
|
1197
1301
|
"type": "object",
|
|
1198
1302
|
"properties": properties,
|
|
1199
1303
|
"required": required,
|
|
1200
1304
|
}
|
|
1305
|
+
|
|
1306
|
+
# Add schema definitions if available
|
|
1307
|
+
if route.schema_definitions:
|
|
1308
|
+
result["$defs"] = route.schema_definitions
|
|
1309
|
+
|
|
1310
|
+
# Use compress_schema to remove unused definitions
|
|
1311
|
+
from fastmcp.utilities.json_schema import compress_schema
|
|
1312
|
+
|
|
1313
|
+
result = compress_schema(result)
|
|
1314
|
+
|
|
1315
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastmcp
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.5
|
|
4
4
|
Summary: The fast, Pythonic way to build MCP servers.
|
|
5
5
|
Project-URL: Homepage, https://gofastmcp.com
|
|
6
6
|
Project-URL: Repository, https://github.com/jlowin/fastmcp
|
|
@@ -19,7 +19,7 @@ Classifier: Typing :: Typed
|
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Requires-Dist: exceptiongroup>=1.2.2
|
|
21
21
|
Requires-Dist: httpx>=0.28.1
|
|
22
|
-
Requires-Dist: mcp<2.0.0,>=1.
|
|
22
|
+
Requires-Dist: mcp<2.0.0,>=1.9.0
|
|
23
23
|
Requires-Dist: openapi-pydantic>=0.5.1
|
|
24
24
|
Requires-Dist: python-dotenv>=1.1.0
|
|
25
25
|
Requires-Dist: rich>=13.9.4
|
|
@@ -290,7 +290,7 @@ FastMCP introduces powerful ways to structure and deploy your MCP applications.
|
|
|
290
290
|
|
|
291
291
|
### Proxy Servers
|
|
292
292
|
|
|
293
|
-
Create a FastMCP server that acts as an intermediary for another local or remote MCP server using `FastMCP.
|
|
293
|
+
Create a FastMCP server that acts as an intermediary for another local or remote MCP server using `FastMCP.as_proxy()`. This is especially useful for bridging transports (e.g., remote SSE to local Stdio) or adding a layer of logic to a server you don't control.
|
|
294
294
|
|
|
295
295
|
Learn more in the [**Proxying Documentation**](https://gofastmcp.com/patterns/proxy).
|
|
296
296
|
|