fastmcp 2.11.2__py3-none-any.whl → 2.11.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/client/logging.py +25 -1
- fastmcp/client/transports.py +4 -3
- fastmcp/experimental/server/openapi/routing.py +1 -1
- fastmcp/experimental/server/openapi/server.py +10 -23
- fastmcp/experimental/utilities/openapi/__init__.py +2 -2
- fastmcp/experimental/utilities/openapi/formatters.py +34 -0
- fastmcp/experimental/utilities/openapi/models.py +5 -2
- fastmcp/experimental/utilities/openapi/parser.py +248 -70
- fastmcp/experimental/utilities/openapi/schemas.py +135 -106
- fastmcp/prompts/prompt_manager.py +2 -2
- fastmcp/resources/resource_manager.py +12 -6
- fastmcp/server/auth/__init__.py +9 -1
- fastmcp/server/auth/auth.py +17 -1
- fastmcp/server/auth/providers/jwt.py +3 -4
- fastmcp/server/auth/registry.py +1 -1
- fastmcp/server/dependencies.py +32 -2
- fastmcp/server/http.py +41 -34
- fastmcp/server/server.py +9 -5
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +7 -7
- fastmcp/tools/tool_manager.py +3 -1
- fastmcp/tools/tool_transform.py +41 -27
- fastmcp/utilities/components.py +19 -4
- fastmcp/utilities/openapi.py +4 -4
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/METADATA +2 -2
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/RECORD +29 -29
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.11.3.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/logging.py
CHANGED
|
@@ -13,7 +13,31 @@ LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
async def default_log_handler(message: LogMessage) -> None:
|
|
16
|
-
|
|
16
|
+
"""Default handler that properly routes server log messages to appropriate log levels."""
|
|
17
|
+
msg = message.data.get("msg", str(message))
|
|
18
|
+
extra = message.data.get("extra", {})
|
|
19
|
+
|
|
20
|
+
# Map MCP log levels to Python logging levels
|
|
21
|
+
level_map = {
|
|
22
|
+
"debug": logger.debug,
|
|
23
|
+
"info": logger.info,
|
|
24
|
+
"notice": logger.info, # Python doesn't have 'notice', map to info
|
|
25
|
+
"warning": logger.warning,
|
|
26
|
+
"error": logger.error,
|
|
27
|
+
"critical": logger.critical,
|
|
28
|
+
"alert": logger.critical, # Map alert to critical
|
|
29
|
+
"emergency": logger.critical, # Map emergency to critical
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Get the appropriate logging function based on the message level
|
|
33
|
+
log_fn = level_map.get(message.level.lower(), logger.info)
|
|
34
|
+
|
|
35
|
+
# Include logger name if available
|
|
36
|
+
if message.logger:
|
|
37
|
+
msg = f"[{message.logger}] {msg}"
|
|
38
|
+
|
|
39
|
+
# Log with appropriate level and extra data
|
|
40
|
+
log_fn(f"Server log: {msg}", extra=extra)
|
|
17
41
|
|
|
18
42
|
|
|
19
43
|
def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:
|
fastmcp/client/transports.py
CHANGED
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import shutil
|
|
7
7
|
import sys
|
|
8
8
|
import warnings
|
|
9
|
-
from collections.abc import AsyncIterator
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Literal, TypeVar, cast, overload
|
|
12
12
|
|
|
@@ -22,6 +22,7 @@ from mcp.client.session import (
|
|
|
22
22
|
SamplingFnT,
|
|
23
23
|
)
|
|
24
24
|
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
|
25
|
+
from mcp.shared._httpx_utils import McpHttpClientFactory
|
|
25
26
|
from mcp.shared.memory import create_client_server_memory_streams
|
|
26
27
|
from pydantic import AnyUrl
|
|
27
28
|
from typing_extensions import TypedDict, Unpack
|
|
@@ -161,7 +162,7 @@ class SSETransport(ClientTransport):
|
|
|
161
162
|
headers: dict[str, str] | None = None,
|
|
162
163
|
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
|
163
164
|
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
|
164
|
-
httpx_client_factory:
|
|
165
|
+
httpx_client_factory: McpHttpClientFactory | None = None,
|
|
165
166
|
):
|
|
166
167
|
if isinstance(url, AnyUrl):
|
|
167
168
|
url = str(url)
|
|
@@ -233,7 +234,7 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
233
234
|
headers: dict[str, str] | None = None,
|
|
234
235
|
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
|
235
236
|
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
|
236
|
-
httpx_client_factory:
|
|
237
|
+
httpx_client_factory: McpHttpClientFactory | None = None,
|
|
237
238
|
):
|
|
238
239
|
if isinstance(url, AnyUrl):
|
|
239
240
|
url = str(url)
|
|
@@ -113,7 +113,7 @@ def _determine_route_type(
|
|
|
113
113
|
# We know mcp_type is not None here due to post_init validation
|
|
114
114
|
assert route_map.mcp_type is not None
|
|
115
115
|
logger.debug(
|
|
116
|
-
f"Route {route.method} {route.path}
|
|
116
|
+
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
|
|
117
117
|
)
|
|
118
118
|
return route_map
|
|
119
119
|
|
|
@@ -11,7 +11,7 @@ from jsonschema_path import SchemaPath
|
|
|
11
11
|
from fastmcp.experimental.utilities.openapi import (
|
|
12
12
|
HTTPRoute,
|
|
13
13
|
extract_output_schema_from_responses,
|
|
14
|
-
|
|
14
|
+
format_simple_description,
|
|
15
15
|
parse_openapi_to_http_routes,
|
|
16
16
|
)
|
|
17
17
|
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
|
@@ -151,9 +151,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
151
151
|
try:
|
|
152
152
|
self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
|
|
153
153
|
self._director = RequestDirector(self._spec)
|
|
154
|
-
logger.debug(
|
|
155
|
-
"Initialized OpenAPI RequestDirector for stateless request building"
|
|
156
|
-
)
|
|
157
154
|
except Exception as e:
|
|
158
155
|
logger.error(f"Failed to initialize RequestDirector: {e}")
|
|
159
156
|
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
|
|
@@ -270,7 +267,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
270
267
|
|
|
271
268
|
# Extract output schema from OpenAPI responses
|
|
272
269
|
output_schema = extract_output_schema_from_responses(
|
|
273
|
-
route.responses,
|
|
270
|
+
route.responses,
|
|
271
|
+
route.response_schemas,
|
|
272
|
+
route.openapi_version,
|
|
274
273
|
)
|
|
275
274
|
|
|
276
275
|
# Get a unique tool name
|
|
@@ -282,10 +281,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
282
281
|
or f"Executes {route.method} {route.path}"
|
|
283
282
|
)
|
|
284
283
|
|
|
285
|
-
#
|
|
286
|
-
enhanced_description =
|
|
284
|
+
# Use simplified description formatter for tools
|
|
285
|
+
enhanced_description = format_simple_description(
|
|
287
286
|
base_description=base_description,
|
|
288
|
-
responses=route.responses,
|
|
289
287
|
parameters=route.parameters,
|
|
290
288
|
request_body=route.request_body,
|
|
291
289
|
)
|
|
@@ -318,9 +316,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
318
316
|
|
|
319
317
|
# Register the tool by directly assigning to the tools dictionary
|
|
320
318
|
self._tool_manager._tools[final_tool_name] = tool
|
|
321
|
-
logger.debug(
|
|
322
|
-
f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
|
323
|
-
)
|
|
324
319
|
|
|
325
320
|
def _create_openapi_resource(
|
|
326
321
|
self,
|
|
@@ -337,10 +332,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
337
332
|
route.description or route.summary or f"Represents {route.path}"
|
|
338
333
|
)
|
|
339
334
|
|
|
340
|
-
#
|
|
341
|
-
enhanced_description =
|
|
335
|
+
# Use simplified description for resources
|
|
336
|
+
enhanced_description = format_simple_description(
|
|
342
337
|
base_description=base_description,
|
|
343
|
-
responses=route.responses,
|
|
344
338
|
parameters=route.parameters,
|
|
345
339
|
request_body=route.request_body,
|
|
346
340
|
)
|
|
@@ -372,9 +366,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
372
366
|
|
|
373
367
|
# Register the resource by directly assigning to the resources dictionary
|
|
374
368
|
self._resource_manager._resources[final_resource_uri] = resource
|
|
375
|
-
logger.debug(
|
|
376
|
-
f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
377
|
-
)
|
|
378
369
|
|
|
379
370
|
def _create_openapi_template(
|
|
380
371
|
self,
|
|
@@ -397,10 +388,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
397
388
|
route.description or route.summary or f"Template for {route.path}"
|
|
398
389
|
)
|
|
399
390
|
|
|
400
|
-
#
|
|
401
|
-
enhanced_description =
|
|
391
|
+
# Use simplified description for resource templates
|
|
392
|
+
enhanced_description = format_simple_description(
|
|
402
393
|
base_description=base_description,
|
|
403
|
-
responses=route.responses,
|
|
404
394
|
parameters=route.parameters,
|
|
405
395
|
request_body=route.request_body,
|
|
406
396
|
)
|
|
@@ -455,9 +445,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
455
445
|
|
|
456
446
|
# Register the template by directly assigning to the templates dictionary
|
|
457
447
|
self._resource_manager._templates[final_template_uri] = template
|
|
458
|
-
logger.debug(
|
|
459
|
-
f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
460
|
-
)
|
|
461
448
|
|
|
462
449
|
|
|
463
450
|
# Export public symbols
|
|
@@ -20,6 +20,7 @@ from .formatters import (
|
|
|
20
20
|
format_deep_object_parameter,
|
|
21
21
|
format_description_with_responses,
|
|
22
22
|
format_json_for_description,
|
|
23
|
+
format_simple_description,
|
|
23
24
|
generate_example_from_schema,
|
|
24
25
|
)
|
|
25
26
|
|
|
@@ -28,7 +29,6 @@ from .schemas import (
|
|
|
28
29
|
_combine_schemas,
|
|
29
30
|
extract_output_schema_from_responses,
|
|
30
31
|
clean_schema_for_display,
|
|
31
|
-
_replace_ref_with_defs,
|
|
32
32
|
_make_optional_parameter_nullable,
|
|
33
33
|
)
|
|
34
34
|
|
|
@@ -55,12 +55,12 @@ __all__ = [
|
|
|
55
55
|
"format_deep_object_parameter",
|
|
56
56
|
"format_description_with_responses",
|
|
57
57
|
"format_json_for_description",
|
|
58
|
+
"format_simple_description",
|
|
58
59
|
"generate_example_from_schema",
|
|
59
60
|
# Schemas
|
|
60
61
|
"_combine_schemas",
|
|
61
62
|
"extract_output_schema_from_responses",
|
|
62
63
|
"clean_schema_for_display",
|
|
63
|
-
"_replace_ref_with_defs",
|
|
64
64
|
"_make_optional_parameter_nullable",
|
|
65
65
|
# JSON Schema Converter
|
|
66
66
|
"convert_openapi_schema_to_json_schema",
|
|
@@ -189,6 +189,39 @@ def format_json_for_description(data: Any, indent: int = 2) -> str:
|
|
|
189
189
|
return f"```\nCould not serialize to JSON: {data}\n```"
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def format_simple_description(
|
|
193
|
+
base_description: str,
|
|
194
|
+
parameters: list[ParameterInfo] | None = None,
|
|
195
|
+
request_body: RequestBodyInfo | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""
|
|
198
|
+
Formats a simple description for MCP objects (tools, resources, prompts).
|
|
199
|
+
Excludes response details, examples, and verbose status codes.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
base_description (str): The initial description to be formatted.
|
|
203
|
+
parameters (list[ParameterInfo] | None, optional): A list of parameter information.
|
|
204
|
+
request_body (RequestBodyInfo | None, optional): Information about the request body.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
str: The formatted description string with minimal details.
|
|
208
|
+
"""
|
|
209
|
+
desc_parts = [base_description]
|
|
210
|
+
|
|
211
|
+
# Only add critical parameter information if they have descriptions
|
|
212
|
+
if parameters:
|
|
213
|
+
path_params = [p for p in parameters if p.location == "path" and p.description]
|
|
214
|
+
if path_params:
|
|
215
|
+
desc_parts.append("\n\n**Path Parameters:**")
|
|
216
|
+
for param in path_params:
|
|
217
|
+
desc_parts.append(f"\n- **{param.name}**: {param.description}")
|
|
218
|
+
|
|
219
|
+
# Skip query parameters, request body details, and all response information
|
|
220
|
+
# These are already captured in the inputSchema
|
|
221
|
+
|
|
222
|
+
return "\n".join(desc_parts)
|
|
223
|
+
|
|
224
|
+
|
|
192
225
|
def format_description_with_responses(
|
|
193
226
|
base_description: str,
|
|
194
227
|
responses: dict[
|
|
@@ -351,5 +384,6 @@ __all__ = [
|
|
|
351
384
|
"format_deep_object_parameter",
|
|
352
385
|
"format_description_with_responses",
|
|
353
386
|
"format_json_for_description",
|
|
387
|
+
"format_simple_description",
|
|
354
388
|
"generate_example_from_schema",
|
|
355
389
|
]
|
|
@@ -58,9 +58,12 @@ class HTTPRoute(FastMCPBaseModel):
|
|
|
58
58
|
responses: dict[str, ResponseInfo] = Field(
|
|
59
59
|
default_factory=dict
|
|
60
60
|
) # Key: status code str
|
|
61
|
-
|
|
61
|
+
request_schemas: dict[str, JsonSchema] = Field(
|
|
62
62
|
default_factory=dict
|
|
63
|
-
) # Store
|
|
63
|
+
) # Store schemas needed for input (parameters/request body)
|
|
64
|
+
response_schemas: dict[str, JsonSchema] = Field(
|
|
65
|
+
default_factory=dict
|
|
66
|
+
) # Store schemas needed for output (responses)
|
|
64
67
|
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
65
68
|
openapi_version: str | None = None
|
|
66
69
|
|
|
@@ -34,7 +34,10 @@ from .models import (
|
|
|
34
34
|
RequestBodyInfo,
|
|
35
35
|
ResponseInfo,
|
|
36
36
|
)
|
|
37
|
-
from .schemas import
|
|
37
|
+
from .schemas import (
|
|
38
|
+
_combine_schemas_and_map_params,
|
|
39
|
+
_replace_ref_with_defs,
|
|
40
|
+
)
|
|
38
41
|
|
|
39
42
|
logger = get_logger(__name__)
|
|
40
43
|
|
|
@@ -63,7 +66,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
|
63
66
|
if openapi_version.startswith("3.0"):
|
|
64
67
|
# Use OpenAPI 3.0 models
|
|
65
68
|
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
|
|
66
|
-
logger.
|
|
69
|
+
logger.debug(
|
|
67
70
|
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
|
68
71
|
)
|
|
69
72
|
parser = OpenAPIParser(
|
|
@@ -81,7 +84,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
|
81
84
|
else:
|
|
82
85
|
# Default to OpenAPI 3.1 models
|
|
83
86
|
openapi_31 = OpenAPI.model_validate(openapi_dict)
|
|
84
|
-
logger.
|
|
87
|
+
logger.debug(
|
|
85
88
|
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
|
86
89
|
)
|
|
87
90
|
parser = OpenAPIParser(
|
|
@@ -207,7 +210,7 @@ class OpenAPIParser(
|
|
|
207
210
|
try:
|
|
208
211
|
resolved_schema = self._resolve_ref(schema_obj)
|
|
209
212
|
|
|
210
|
-
if isinstance(resolved_schema,
|
|
213
|
+
if isinstance(resolved_schema, self.schema_cls):
|
|
211
214
|
# Convert schema to dictionary
|
|
212
215
|
result = resolved_schema.model_dump(
|
|
213
216
|
mode="json", by_alias=True, exclude_none=True
|
|
@@ -220,7 +223,10 @@ class OpenAPIParser(
|
|
|
220
223
|
)
|
|
221
224
|
result = {}
|
|
222
225
|
|
|
223
|
-
|
|
226
|
+
# Convert refs from OpenAPI format to JSON Schema format using recursive approach
|
|
227
|
+
|
|
228
|
+
result = _replace_ref_with_defs(result)
|
|
229
|
+
return result
|
|
224
230
|
except ValueError as e:
|
|
225
231
|
# Re-raise ValueError for external reference errors and other validation issues
|
|
226
232
|
if "External or non-local reference not supported" in str(e):
|
|
@@ -396,80 +402,235 @@ class OpenAPIParser(
|
|
|
396
402
|
)
|
|
397
403
|
return None
|
|
398
404
|
|
|
405
|
+
def _is_success_status_code(self, status_code: str) -> bool:
|
|
406
|
+
"""Check if a status code represents a successful response (2xx)."""
|
|
407
|
+
try:
|
|
408
|
+
code_int = int(status_code)
|
|
409
|
+
return 200 <= code_int < 300
|
|
410
|
+
except (ValueError, TypeError):
|
|
411
|
+
# Handle special cases like 'default' or other non-numeric codes
|
|
412
|
+
return status_code.lower() in ["default", "2xx"]
|
|
413
|
+
|
|
414
|
+
def _get_primary_success_response(
|
|
415
|
+
self, operation_responses: dict[str, Any]
|
|
416
|
+
) -> tuple[str, Any] | None:
|
|
417
|
+
"""Get the primary success response for an MCP tool. We only need one success response."""
|
|
418
|
+
if not operation_responses:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
# Priority order: 200, 201, 202, 204, 207, then any other 2xx
|
|
422
|
+
priority_codes = ["200", "201", "202", "204", "207"]
|
|
423
|
+
|
|
424
|
+
# First check priority codes
|
|
425
|
+
for code in priority_codes:
|
|
426
|
+
if code in operation_responses:
|
|
427
|
+
return (code, operation_responses[code])
|
|
428
|
+
|
|
429
|
+
# Then check any other 2xx codes
|
|
430
|
+
for status_code, resp_or_ref in operation_responses.items():
|
|
431
|
+
if self._is_success_status_code(status_code):
|
|
432
|
+
return (status_code, resp_or_ref)
|
|
433
|
+
|
|
434
|
+
# If no success codes found, return None (tool will have no output schema)
|
|
435
|
+
return None
|
|
436
|
+
|
|
399
437
|
def _extract_responses(
|
|
400
438
|
self, operation_responses: dict[str, Any] | None
|
|
401
439
|
) -> dict[str, ResponseInfo]:
|
|
402
|
-
"""Extract and resolve response information."""
|
|
440
|
+
"""Extract and resolve response information. Only includes the primary success response for MCP tools."""
|
|
403
441
|
extracted_responses: dict[str, ResponseInfo] = {}
|
|
404
442
|
|
|
405
443
|
if not operation_responses:
|
|
406
444
|
return extracted_responses
|
|
407
445
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
446
|
+
# For MCP tools, we only need the primary success response
|
|
447
|
+
primary_response = self._get_primary_success_response(operation_responses)
|
|
448
|
+
if not primary_response:
|
|
449
|
+
logger.debug("No success responses found, tool will have no output schema")
|
|
450
|
+
return extracted_responses
|
|
411
451
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
f"Expected Response after resolving for status code {status_code}, "
|
|
415
|
-
f"got {type(response)}. Skipping."
|
|
416
|
-
)
|
|
417
|
-
continue
|
|
452
|
+
status_code, resp_or_ref = primary_response
|
|
453
|
+
logger.debug(f"Using primary success response: {status_code}")
|
|
418
454
|
|
|
419
|
-
|
|
420
|
-
|
|
455
|
+
try:
|
|
456
|
+
response = self._resolve_ref(resp_or_ref)
|
|
421
457
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
for
|
|
425
|
-
|
|
426
|
-
media_type_obj
|
|
427
|
-
and hasattr(media_type_obj, "media_type_schema")
|
|
428
|
-
and media_type_obj.media_type_schema
|
|
429
|
-
):
|
|
430
|
-
try:
|
|
431
|
-
schema_dict = self._extract_schema_as_dict(
|
|
432
|
-
media_type_obj.media_type_schema
|
|
433
|
-
)
|
|
434
|
-
resp_info.content_schema[media_type_str] = schema_dict
|
|
435
|
-
except ValueError as e:
|
|
436
|
-
# Re-raise ValueError for external reference errors
|
|
437
|
-
if (
|
|
438
|
-
"External or non-local reference not supported"
|
|
439
|
-
in str(e)
|
|
440
|
-
):
|
|
441
|
-
raise
|
|
442
|
-
logger.error(
|
|
443
|
-
f"Failed to extract schema for media type '{media_type_str}' "
|
|
444
|
-
f"in response {status_code}: {e}"
|
|
445
|
-
)
|
|
446
|
-
except Exception as e:
|
|
447
|
-
logger.error(
|
|
448
|
-
f"Failed to extract schema for media type '{media_type_str}' "
|
|
449
|
-
f"in response {status_code}: {e}"
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
extracted_responses[str(status_code)] = resp_info
|
|
453
|
-
except ValueError as e:
|
|
454
|
-
# Re-raise ValueError for external reference errors
|
|
455
|
-
if "External or non-local reference not supported" in str(e):
|
|
456
|
-
raise
|
|
457
|
-
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
458
|
-
logger.error(
|
|
459
|
-
f"Failed to extract response for status code {status_code} "
|
|
460
|
-
f"from reference '{ref_name}': {e}",
|
|
461
|
-
exc_info=False,
|
|
462
|
-
)
|
|
463
|
-
except Exception as e:
|
|
464
|
-
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
465
|
-
logger.error(
|
|
466
|
-
f"Failed to extract response for status code {status_code} "
|
|
467
|
-
f"from reference '{ref_name}': {e}",
|
|
468
|
-
exc_info=False,
|
|
458
|
+
if not isinstance(response, self.response_cls):
|
|
459
|
+
logger.warning(
|
|
460
|
+
f"Expected Response after resolving for status code {status_code}, "
|
|
461
|
+
f"got {type(response)}. Returning empty responses."
|
|
469
462
|
)
|
|
463
|
+
return extracted_responses
|
|
464
|
+
|
|
465
|
+
# Create response info
|
|
466
|
+
resp_info = ResponseInfo(description=response.description)
|
|
467
|
+
|
|
468
|
+
# Extract content schemas
|
|
469
|
+
if hasattr(response, "content") and response.content:
|
|
470
|
+
for media_type_str, media_type_obj in response.content.items():
|
|
471
|
+
if (
|
|
472
|
+
media_type_obj
|
|
473
|
+
and hasattr(media_type_obj, "media_type_schema")
|
|
474
|
+
and media_type_obj.media_type_schema
|
|
475
|
+
):
|
|
476
|
+
try:
|
|
477
|
+
schema_dict = self._extract_schema_as_dict(
|
|
478
|
+
media_type_obj.media_type_schema
|
|
479
|
+
)
|
|
480
|
+
resp_info.content_schema[media_type_str] = schema_dict
|
|
481
|
+
except ValueError as e:
|
|
482
|
+
# Re-raise ValueError for external reference errors
|
|
483
|
+
if "External or non-local reference not supported" in str(
|
|
484
|
+
e
|
|
485
|
+
):
|
|
486
|
+
raise
|
|
487
|
+
logger.error(
|
|
488
|
+
f"Failed to extract schema for media type '{media_type_str}' "
|
|
489
|
+
f"in response {status_code}: {e}"
|
|
490
|
+
)
|
|
491
|
+
except Exception as e:
|
|
492
|
+
logger.error(
|
|
493
|
+
f"Failed to extract schema for media type '{media_type_str}' "
|
|
494
|
+
f"in response {status_code}: {e}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
extracted_responses[str(status_code)] = resp_info
|
|
498
|
+
except ValueError as e:
|
|
499
|
+
# Re-raise ValueError for external reference errors
|
|
500
|
+
if "External or non-local reference not supported" in str(e):
|
|
501
|
+
raise
|
|
502
|
+
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
503
|
+
logger.error(
|
|
504
|
+
f"Failed to extract response for status code {status_code} "
|
|
505
|
+
f"from reference '{ref_name}': {e}",
|
|
506
|
+
exc_info=False,
|
|
507
|
+
)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
510
|
+
logger.error(
|
|
511
|
+
f"Failed to extract response for status code {status_code} "
|
|
512
|
+
f"from reference '{ref_name}': {e}",
|
|
513
|
+
exc_info=False,
|
|
514
|
+
)
|
|
470
515
|
|
|
471
516
|
return extracted_responses
|
|
472
517
|
|
|
518
|
+
def _extract_schema_dependencies(
|
|
519
|
+
self,
|
|
520
|
+
schema: dict,
|
|
521
|
+
all_schemas: dict[str, Any],
|
|
522
|
+
collected: set[str] | None = None,
|
|
523
|
+
) -> set[str]:
|
|
524
|
+
"""
|
|
525
|
+
Extract all schema names referenced by a schema (including transitive dependencies).
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
schema: The schema to analyze
|
|
529
|
+
all_schemas: All available schema definitions
|
|
530
|
+
collected: Set of already collected schema names (for recursion)
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Set of schema names that are referenced
|
|
534
|
+
"""
|
|
535
|
+
if collected is None:
|
|
536
|
+
collected = set()
|
|
537
|
+
|
|
538
|
+
def find_refs(obj):
|
|
539
|
+
"""Recursively find all $ref references."""
|
|
540
|
+
if isinstance(obj, dict):
|
|
541
|
+
if "$ref" in obj and isinstance(obj["$ref"], str):
|
|
542
|
+
ref = obj["$ref"]
|
|
543
|
+
# Handle both converted and unconverted refs
|
|
544
|
+
if ref.startswith("#/$defs/"):
|
|
545
|
+
schema_name = ref.split("/")[-1]
|
|
546
|
+
elif ref.startswith("#/components/schemas/"):
|
|
547
|
+
schema_name = ref.split("/")[-1]
|
|
548
|
+
else:
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
# Add this schema and recursively find its dependencies
|
|
552
|
+
if schema_name not in collected and schema_name in all_schemas:
|
|
553
|
+
collected.add(schema_name)
|
|
554
|
+
# Recursively find dependencies of this schema
|
|
555
|
+
find_refs(all_schemas[schema_name])
|
|
556
|
+
|
|
557
|
+
# Continue searching in all values
|
|
558
|
+
for value in obj.values():
|
|
559
|
+
find_refs(value)
|
|
560
|
+
elif isinstance(obj, list):
|
|
561
|
+
for item in obj:
|
|
562
|
+
find_refs(item)
|
|
563
|
+
|
|
564
|
+
find_refs(schema)
|
|
565
|
+
return collected
|
|
566
|
+
|
|
567
|
+
def _extract_input_schema_dependencies(
|
|
568
|
+
self,
|
|
569
|
+
parameters: list[ParameterInfo],
|
|
570
|
+
request_body: RequestBodyInfo | None,
|
|
571
|
+
all_schemas: dict[str, Any],
|
|
572
|
+
) -> dict[str, Any]:
|
|
573
|
+
"""
|
|
574
|
+
Extract only the schema definitions needed for input (parameters and request body).
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
parameters: Route parameters
|
|
578
|
+
request_body: Route request body
|
|
579
|
+
all_schemas: All available schema definitions
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
Dictionary containing only the schemas needed for input
|
|
583
|
+
"""
|
|
584
|
+
needed_schemas = set()
|
|
585
|
+
|
|
586
|
+
# Check parameters for schema references
|
|
587
|
+
for param in parameters:
|
|
588
|
+
if param.schema_:
|
|
589
|
+
deps = self._extract_schema_dependencies(param.schema_, all_schemas)
|
|
590
|
+
needed_schemas.update(deps)
|
|
591
|
+
|
|
592
|
+
# Check request body for schema references
|
|
593
|
+
if request_body and request_body.content_schema:
|
|
594
|
+
for content_schema in request_body.content_schema.values():
|
|
595
|
+
deps = self._extract_schema_dependencies(content_schema, all_schemas)
|
|
596
|
+
needed_schemas.update(deps)
|
|
597
|
+
|
|
598
|
+
# Return only the needed input schemas
|
|
599
|
+
return {
|
|
600
|
+
name: all_schemas[name] for name in needed_schemas if name in all_schemas
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
def _extract_output_schema_dependencies(
|
|
604
|
+
self,
|
|
605
|
+
responses: dict[str, ResponseInfo],
|
|
606
|
+
all_schemas: dict[str, Any],
|
|
607
|
+
) -> dict[str, Any]:
|
|
608
|
+
"""
|
|
609
|
+
Extract only the schema definitions needed for outputs (responses).
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
responses: Route responses
|
|
613
|
+
all_schemas: All available schema definitions
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
Dictionary containing only the schemas needed for outputs
|
|
617
|
+
"""
|
|
618
|
+
needed_schemas = set()
|
|
619
|
+
|
|
620
|
+
# Check responses for schema references
|
|
621
|
+
for response in responses.values():
|
|
622
|
+
if response.content_schema:
|
|
623
|
+
for content_schema in response.content_schema.values():
|
|
624
|
+
deps = self._extract_schema_dependencies(
|
|
625
|
+
content_schema, all_schemas
|
|
626
|
+
)
|
|
627
|
+
needed_schemas.update(deps)
|
|
628
|
+
|
|
629
|
+
# Return only the needed output schemas
|
|
630
|
+
return {
|
|
631
|
+
name: all_schemas[name] for name in needed_schemas if name in all_schemas
|
|
632
|
+
}
|
|
633
|
+
|
|
473
634
|
def parse(self) -> list[HTTPRoute]:
|
|
474
635
|
"""Parse the OpenAPI schema into HTTP routes."""
|
|
475
636
|
routes: list[HTTPRoute] = []
|
|
@@ -499,6 +660,13 @@ class OpenAPIParser(
|
|
|
499
660
|
f"Failed to extract schema definition '{name}': {e}"
|
|
500
661
|
)
|
|
501
662
|
|
|
663
|
+
# Convert schema definitions refs from OpenAPI to JSON Schema format (once)
|
|
664
|
+
if schema_definitions:
|
|
665
|
+
# Convert each schema definition recursively
|
|
666
|
+
for name, schema in schema_definitions.items():
|
|
667
|
+
if isinstance(schema, dict):
|
|
668
|
+
schema_definitions[name] = _replace_ref_with_defs(schema)
|
|
669
|
+
|
|
502
670
|
# Process paths and operations
|
|
503
671
|
for path_str, path_item_obj in self.openapi.paths.items():
|
|
504
672
|
if not isinstance(path_item_obj, self.path_item_cls):
|
|
@@ -552,6 +720,17 @@ class OpenAPIParser(
|
|
|
552
720
|
if k.startswith("x-")
|
|
553
721
|
}
|
|
554
722
|
|
|
723
|
+
# Extract schemas separately for input and output
|
|
724
|
+
input_schemas = self._extract_input_schema_dependencies(
|
|
725
|
+
parameters,
|
|
726
|
+
request_body_info,
|
|
727
|
+
schema_definitions,
|
|
728
|
+
)
|
|
729
|
+
output_schemas = self._extract_output_schema_dependencies(
|
|
730
|
+
responses,
|
|
731
|
+
schema_definitions,
|
|
732
|
+
)
|
|
733
|
+
|
|
555
734
|
# Create initial route without pre-calculated fields
|
|
556
735
|
route = HTTPRoute(
|
|
557
736
|
path=path_str,
|
|
@@ -563,7 +742,8 @@ class OpenAPIParser(
|
|
|
563
742
|
parameters=parameters,
|
|
564
743
|
request_body=request_body_info,
|
|
565
744
|
responses=responses,
|
|
566
|
-
|
|
745
|
+
request_schemas=input_schemas,
|
|
746
|
+
response_schemas=output_schemas,
|
|
567
747
|
extensions=extensions,
|
|
568
748
|
openapi_version=self.openapi_version,
|
|
569
749
|
)
|
|
@@ -571,7 +751,8 @@ class OpenAPIParser(
|
|
|
571
751
|
# Pre-calculate schema and parameter mapping for performance
|
|
572
752
|
try:
|
|
573
753
|
flat_schema, param_map = _combine_schemas_and_map_params(
|
|
574
|
-
route
|
|
754
|
+
route,
|
|
755
|
+
convert_refs=False, # Parser already converted refs
|
|
575
756
|
)
|
|
576
757
|
route.flat_param_schema = flat_schema
|
|
577
758
|
route.parameter_map = param_map
|
|
@@ -586,9 +767,6 @@ class OpenAPIParser(
|
|
|
586
767
|
}
|
|
587
768
|
route.parameter_map = {}
|
|
588
769
|
routes.append(route)
|
|
589
|
-
logger.info(
|
|
590
|
-
f"Successfully extracted route: {method_upper} {path_str}"
|
|
591
|
-
)
|
|
592
770
|
except ValueError as op_error:
|
|
593
771
|
# Re-raise ValueError for external reference errors
|
|
594
772
|
if "External or non-local reference not supported" in str(
|
|
@@ -607,7 +785,7 @@ class OpenAPIParser(
|
|
|
607
785
|
exc_info=True,
|
|
608
786
|
)
|
|
609
787
|
|
|
610
|
-
logger.
|
|
788
|
+
logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
|
611
789
|
return routes
|
|
612
790
|
|
|
613
791
|
|