From b501f05794203c278fd07c906614815dff8390a0 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 1 Dec 2025 20:29:18 -0500
Subject: [PATCH] Switch to new OpenAPI parser as default (#2513)
* Switch to new OpenAPI parser as default
Remove the legacy OpenAPI parser and make the experimental parser the
default. The experimental parser (introduced in 2.11) offers better
performance, improved compatibility, and a more maintainable architecture.
- Delete legacy parser (server/openapi.py, utilities/openapi.py)
- Move experimental parser to main locations
- Remove enable_new_openapi_parser feature flag
- Update documentation to remove experimental references
* Add deprecation stubs for experimental OpenAPI imports
* Add deprecated enable_new_openapi_parser setting and deprecation tests
* SDK docs
* REview comments
* Fix docstrings
* Update docstring
* Review comments
* Fix broken links
---
AGENTS.md | 2 +-
docs/docs.json | 23 +-
docs/integrations/fastapi.mdx | 9 -
docs/integrations/oci.mdx | 8 +-
docs/integrations/openapi.mdx | 28 +-
.../fastmcp-server-auth-oauth_proxy.mdx | 2 +-
.../fastmcp-server-openapi-__init__.mdx | 9 +
.../fastmcp-server-openapi-components.mdx | 62 +
.../fastmcp-server-openapi-routing.mdx | 23 +
.../fastmcp-server-openapi-server.mdx | 20 +
docs/python-sdk/fastmcp-server-openapi.mdx | 91 -
docs/python-sdk/fastmcp-server-server.mdx | 100 +-
docs/python-sdk/fastmcp-settings.mdx | 12 +-
docs/python-sdk/fastmcp-tools-tool.mdx | 30 +-
.../fastmcp-utilities-openapi-__init__.mdx | 9 +
.../fastmcp-utilities-openapi-director.mdx | 36 +
.../fastmcp-utilities-openapi-formatters.mdx | 115 ++
...tilities-openapi-json_schema_converter.mdx | 62 +
.../fastmcp-utilities-openapi-models.mdx | 35 +
.../fastmcp-utilities-openapi-parser.mdx | 43 +
.../fastmcp-utilities-openapi-schemas.mdx | 43 +
docs/python-sdk/fastmcp-utilities-openapi.mdx | 180 --
.../experimental/server/openapi/__init__.py | 30 +-
.../utilities/openapi/__init__.py | 52 +-
src/fastmcp/server/auth/oauth_proxy.py | 2 +-
src/fastmcp/server/openapi.py | 1087 ------------
.../server/openapi/README.md | 0
src/fastmcp/server/openapi/__init__.py | 35 +
.../server/openapi/components.py | 7 +-
.../server/openapi/routing.py | 2 +-
.../server/openapi/server.py | 11 +-
src/fastmcp/server/server.py | 113 +-
src/fastmcp/settings.py | 27 +-
src/fastmcp/utilities/openapi.py | 1568 -----------------
.../utilities/openapi/README.md | 42 +-
src/fastmcp/utilities/openapi/__init__.py | 63 +
.../utilities/openapi/director.py | 0
.../utilities/openapi/formatters.py | 10 +-
.../openapi/json_schema_converter.py | 2 +-
.../utilities/openapi/models.py | 0
.../utilities/openapi/parser.py | 0
.../utilities/openapi/schemas.py | 0
...test_openapi_legacy.py => test_openapi.py} | 8 +-
tests/client/test_openapi_experimental.py | 194 --
tests/deprecated/test_openapi_deprecations.py | 73 +
tests/deprecated/test_route_type_ignore.py | 116 --
tests/experimental/openapi_parser/README.md | 5 -
tests/experimental/openapi_parser/__init__.py | 0
tests/experimental/openapi_parser/conftest.py | 11 -
.../openapi_parser/server/__init__.py | 0
.../openapi_parser/server/openapi/__init__.py | 1 -
.../server/openapi/test_deepobject_style.py | 333 ----
.../openapi/test_parameter_collisions.py | 212 ---
.../openapi_parser/utilities/__init__.py | 0
.../utilities/openapi/__init__.py | 1 -
.../utilities/openapi/conftest.py | 222 ---
.../openapi/test_legacy_compatibility.py | 333 ----
.../utilities/openapi/test_nullable_fields.py | 375 ----
tests/server/openapi/__init__.py | 1 +
tests/server/openapi/conftest.py | 135 --
.../server/openapi/test_advanced_behavior.py | 315 ----
.../openapi/test_basic_functionality.py | 369 ----
.../server/openapi/test_comprehensive.py | 2 +-
tests/server/openapi/test_configuration.py | 933 ----------
tests/server/openapi/test_deepobject_style.py | 488 ++---
.../openapi/test_description_propagation.py | 796 ---------
.../openapi/test_end_to_end_compatibility.py | 2 +-
.../openapi/test_explode_integration.py | 320 ----
.../openapi/test_openapi_compatibility.py | 661 -------
.../server/openapi/test_openapi_features.py | 2 +-
.../openapi/test_openapi_path_parameters.py | 624 -------
.../openapi/test_openapi_performance.py | 7 -
.../openapi/test_optional_parameters.py | 100 --
.../openapi/test_parameter_collisions.py | 424 ++---
.../openapi/test_performance_comparison.py | 2 +-
tests/server/openapi/test_route_map_fn.py | 452 -----
.../server/openapi/test_server.py | 2 +-
.../test_experimental_openapi_feature_flag.py | 98 --
tests/server/test_server.py | 153 +-
tests/utilities/openapi/__init__.py | 2 +-
tests/utilities/openapi/conftest.py | 221 +++
.../openapi/test_allof_requestbody.py | 4 +-
.../openapi/test_direct_array_schemas.py | 4 +-
.../utilities/openapi/test_director.py | 6 +-
.../openapi/test_legacy_compatibility.py | 192 ++
.../utilities/openapi/test_models.py | 2 +-
.../utilities/openapi/test_nullable_fields.py | 182 +-
tests/utilities/openapi/test_openapi.py | 1285 --------------
.../openapi/test_openapi_advanced.py | 665 -------
.../utilities/openapi/test_openapi_fastapi.py | 540 ------
.../openapi/test_openapi_output_schemas.py | 276 ---
.../utilities/openapi/test_parser.py | 2 +-
.../utilities/openapi/test_schemas.py | 6 +-
.../openapi/test_transitive_references.py | 6 +-
94 files changed, 1887 insertions(+), 13269 deletions(-)
create mode 100644 docs/python-sdk/fastmcp-server-openapi-__init__.mdx
create mode 100644 docs/python-sdk/fastmcp-server-openapi-components.mdx
create mode 100644 docs/python-sdk/fastmcp-server-openapi-routing.mdx
create mode 100644 docs/python-sdk/fastmcp-server-openapi-server.mdx
delete mode 100644 docs/python-sdk/fastmcp-server-openapi.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-__init__.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-director.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-models.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-parser.mdx
create mode 100644 docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx
delete mode 100644 docs/python-sdk/fastmcp-utilities-openapi.mdx
delete mode 100644 src/fastmcp/server/openapi.py
rename src/fastmcp/{experimental => }/server/openapi/README.md (100%)
create mode 100644 src/fastmcp/server/openapi/__init__.py
rename src/fastmcp/{experimental => }/server/openapi/components.py (98%)
rename src/fastmcp/{experimental => }/server/openapi/routing.py (98%)
rename src/fastmcp/{experimental => }/server/openapi/server.py (98%)
delete mode 100644 src/fastmcp/utilities/openapi.py
rename src/fastmcp/{experimental => }/utilities/openapi/README.md (82%)
create mode 100644 src/fastmcp/utilities/openapi/__init__.py
rename src/fastmcp/{experimental => }/utilities/openapi/director.py (100%)
rename src/fastmcp/{experimental => }/utilities/openapi/formatters.py (97%)
rename src/fastmcp/{experimental => }/utilities/openapi/json_schema_converter.py (99%)
rename src/fastmcp/{experimental => }/utilities/openapi/models.py (100%)
rename src/fastmcp/{experimental => }/utilities/openapi/parser.py (100%)
rename src/fastmcp/{experimental => }/utilities/openapi/schemas.py (100%)
rename tests/client/{test_openapi_legacy.py => test_openapi.py} (96%)
delete mode 100644 tests/client/test_openapi_experimental.py
create mode 100644 tests/deprecated/test_openapi_deprecations.py
delete mode 100644 tests/deprecated/test_route_type_ignore.py
delete mode 100644 tests/experimental/openapi_parser/README.md
delete mode 100644 tests/experimental/openapi_parser/__init__.py
delete mode 100644 tests/experimental/openapi_parser/conftest.py
delete mode 100644 tests/experimental/openapi_parser/server/__init__.py
delete mode 100644 tests/experimental/openapi_parser/server/openapi/__init__.py
delete mode 100644 tests/experimental/openapi_parser/server/openapi/test_deepobject_style.py
delete mode 100644 tests/experimental/openapi_parser/server/openapi/test_parameter_collisions.py
delete mode 100644 tests/experimental/openapi_parser/utilities/__init__.py
delete mode 100644 tests/experimental/openapi_parser/utilities/openapi/__init__.py
delete mode 100644 tests/experimental/openapi_parser/utilities/openapi/conftest.py
delete mode 100644 tests/experimental/openapi_parser/utilities/openapi/test_legacy_compatibility.py
delete mode 100644 tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py
delete mode 100644 tests/server/openapi/conftest.py
delete mode 100644 tests/server/openapi/test_advanced_behavior.py
delete mode 100644 tests/server/openapi/test_basic_functionality.py
rename tests/{experimental/openapi_parser => }/server/openapi/test_comprehensive.py (99%)
delete mode 100644 tests/server/openapi/test_configuration.py
delete mode 100644 tests/server/openapi/test_description_propagation.py
rename tests/{experimental/openapi_parser => }/server/openapi/test_end_to_end_compatibility.py (99%)
delete mode 100644 tests/server/openapi/test_explode_integration.py
delete mode 100644 tests/server/openapi/test_openapi_compatibility.py
rename tests/{experimental/openapi_parser => }/server/openapi/test_openapi_features.py (99%)
delete mode 100644 tests/server/openapi/test_openapi_path_parameters.py
rename tests/{experimental/openapi_parser => }/server/openapi/test_openapi_performance.py (96%)
delete mode 100644 tests/server/openapi/test_optional_parameters.py
rename tests/{experimental/openapi_parser => }/server/openapi/test_performance_comparison.py (99%)
delete mode 100644 tests/server/openapi/test_route_map_fn.py
rename tests/{experimental/openapi_parser => }/server/openapi/test_server.py (99%)
delete mode 100644 tests/server/test_experimental_openapi_feature_flag.py
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_allof_requestbody.py (98%)
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_direct_array_schemas.py (98%)
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_director.py (98%)
create mode 100644 tests/utilities/openapi/test_legacy_compatibility.py
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_models.py (99%)
delete mode 100644 tests/utilities/openapi/test_openapi.py
delete mode 100644 tests/utilities/openapi/test_openapi_advanced.py
delete mode 100644 tests/utilities/openapi/test_openapi_fastapi.py
delete mode 100644 tests/utilities/openapi/test_openapi_output_schemas.py
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_parser.py (99%)
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_schemas.py (99%)
rename tests/{experimental/openapi_parser => }/utilities/openapi/test_transitive_references.py (99%)
diff --git a/AGENTS.md b/AGENTS.md
index 9279aed35..f7ddeffa0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,7 +33,7 @@ uv run pytest # Run full test suite
| `├─prompts/` | Prompt templates + `PromptManager` |
| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) |
| `├─contrib/` | Community contributions (bulk caller, mixins) |
-| `├─experimental/` | Experimental features (new OpenAPI parser) |
+| `├─experimental/` | Experimental features (sampling handlers) |
| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) |
| `tests/` | Comprehensive pytest suite with markers |
| `docs/` | Mintlify documentation (published to gofastmcp.com) |
diff --git a/docs/docs.json b/docs/docs.json
index 7e306b642..7faf7afad 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -391,7 +391,15 @@
"python-sdk/fastmcp-server-middleware-tool_injection"
]
},
- "python-sdk/fastmcp-server-openapi",
+ {
+ "group": "openapi",
+ "pages": [
+ "python-sdk/fastmcp-server-openapi-__init__",
+ "python-sdk/fastmcp-server-openapi-components",
+ "python-sdk/fastmcp-server-openapi-routing",
+ "python-sdk/fastmcp-server-openapi-server"
+ ]
+ },
"python-sdk/fastmcp-server-proxy",
"python-sdk/fastmcp-server-server"
]
@@ -448,7 +456,18 @@
}
]
},
- "python-sdk/fastmcp-utilities-openapi",
+ {
+ "group": "openapi",
+ "pages": [
+ "python-sdk/fastmcp-utilities-openapi-__init__",
+ "python-sdk/fastmcp-utilities-openapi-director",
+ "python-sdk/fastmcp-utilities-openapi-formatters",
+ "python-sdk/fastmcp-utilities-openapi-json_schema_converter",
+ "python-sdk/fastmcp-utilities-openapi-models",
+ "python-sdk/fastmcp-utilities-openapi-parser",
+ "python-sdk/fastmcp-utilities-openapi-schemas"
+ ]
+ },
"python-sdk/fastmcp-utilities-tests",
"python-sdk/fastmcp-utilities-types",
"python-sdk/fastmcp-utilities-ui"
diff --git a/docs/integrations/fastapi.mdx b/docs/integrations/fastapi.mdx
index c88a61599..683bc244e 100644
--- a/docs/integrations/fastapi.mdx
+++ b/docs/integrations/fastapi.mdx
@@ -7,12 +7,6 @@ icon: bolt
import { VersionBadge } from '/snippets/version-badge.mdx'
-
-**New in 2.11**: FastMCP is introducing a next-generation OpenAPI parser. The new parser has greatly improved performance and compatibility, and is also easier to maintain. To enable it, set the environment variable `FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true`.
-
-The new parser is largely API-compatible with the existing implementation and will become the default in a future version. We encourage all users to test it and report any issues before it becomes the default.
-
-
FastMCP provides two powerful ways to integrate with FastAPI applications:
1. **[Generate an MCP server FROM your FastAPI app](#generating-an-mcp-server)** - Convert existing API endpoints into MCP tools
@@ -224,9 +218,6 @@ Because FastMCP's FastAPI integration is based on its [OpenAPI integration](/int
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType
-# If using experimental parser, import from experimental module:
-# from fastmcp.experimental.server.openapi import RouteMap, MCPType
-
# Custom mapping rules
mcp = FastMCP.from_fastapi(
app=app,
diff --git a/docs/integrations/oci.mdx b/docs/integrations/oci.mdx
index 537870372..ab46486c4 100644
--- a/docs/integrations/oci.mdx
+++ b/docs/integrations/oci.mdx
@@ -31,7 +31,7 @@ This guide shows you how to secure your FastMCP server using **OCI IAM OAuth**.
Click on "Edit Domain Settings" button.
-
+
@@ -40,7 +40,7 @@ This guide shows you how to secure your FastMCP server using **OCI IAM OAuth**.
Enable "Configure client access" checkbox as shown in the screenshot.
-
+
@@ -66,7 +66,7 @@ Follow the Steps as mentioned below to create an OAuth client.
In the Add application details page, Enter name and description as shown below.
-
+
@@ -79,7 +79,7 @@ Follow the Steps as mentioned below to create an OAuth client.
For Authorization grant type, select redirect URL. In most cases, this will be the MCP server URL followed by "/oauth/callback".
-
+
diff --git a/docs/integrations/openapi.mdx b/docs/integrations/openapi.mdx
index e023535a8..8662a0c05 100644
--- a/docs/integrations/openapi.mdx
+++ b/docs/integrations/openapi.mdx
@@ -9,12 +9,6 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-
-**New in 2.11**: FastMCP is introducing a next-generation OpenAPI parser. The new parser has greatly improved performance and compatibility, and is also easier to maintain. To enable it, set the environment variable `FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true`.
-
-The new parser is largely API-compatible with the existing implementation and will become the default in a future version. We encourage all users to test it and report any issues before it becomes the default.
-
-
FastMCP can automatically generate an MCP server from any OpenAPI specification, allowing AI models to interact with existing APIs through the MCP protocol. Instead of manually creating tools and resources, you provide an OpenAPI spec and FastMCP intelligently converts API endpoints into the appropriate MCP components.
@@ -95,14 +89,6 @@ DEFAULT_ROUTE_MAPPINGS = [
]
```
-
-**Experimental Parser**: If you're using the new parser (enabled via `FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true`), import from the experimental module instead:
-```python
-from fastmcp.experimental.server.openapi import RouteMap, MCPType
-```
-The API is identical, but the implementation provides better performance and serverless compatibility.
-
-
### Custom Route Maps
When creating your FastMCP server, you can customize routing behavior by providing your own list of `RouteMap` objects. Your custom maps are processed before the default route maps, and routes will be assigned to the first matching custom map.
@@ -379,20 +365,12 @@ Your `mcp_component_fn` is expected to modify the component in-place, not to ret
```python
from fastmcp.server.openapi import (
- HTTPRoute,
- OpenAPITool,
- OpenAPIResource,
+ HTTPRoute,
+ OpenAPITool,
+ OpenAPIResource,
OpenAPIResourceTemplate,
)
-# If using experimental parser, import from experimental module:
-# from fastmcp.experimental.server.openapi import (
-# HTTPRoute,
-# OpenAPITool,
-# OpenAPIResource,
-# OpenAPIResourceTemplate,
-# )
-
def customize_components(
route: HTTPRoute,
component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate,
diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
index 3aa245abb..2ce8efee2 100644
--- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
@@ -54,7 +54,7 @@ Create a styled HTML error page for OAuth errors.
**Args:**
- `error_title`: The error title (e.g., "OAuth Error", "Authorization Failed")
- `error_message`: The main error message to display
-- `error_details`: Optional dictionary of error details to show (e.g., {"Error Code"\: "invalid_client"})
+- `error_details`: Optional dictionary of error details to show (e.g., `{"Error Code"\: "invalid_client"}`)
- `server_name`: Optional server name to display
- `server_icon_url`: Optional URL to server icon/logo
diff --git a/docs/python-sdk/fastmcp-server-openapi-__init__.mdx b/docs/python-sdk/fastmcp-server-openapi-__init__.mdx
new file mode 100644
index 000000000..2e2cfe6e2
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-openapi-__init__.mdx
@@ -0,0 +1,9 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.server.openapi`
+
+
+OpenAPI server implementation for FastMCP - refactored for better maintainability.
diff --git a/docs/python-sdk/fastmcp-server-openapi-components.mdx b/docs/python-sdk/fastmcp-server-openapi-components.mdx
new file mode 100644
index 000000000..4a12d363f
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-openapi-components.mdx
@@ -0,0 +1,62 @@
+---
+title: components
+sidebarTitle: components
+---
+
+# `fastmcp.server.openapi.components`
+
+
+OpenAPI component implementations: Tool, Resource, and ResourceTemplate classes.
+
+## Classes
+
+### `OpenAPITool`
+
+
+Tool implementation for OpenAPI endpoints.
+
+
+**Methods:**
+
+#### `run`
+
+```python
+run(self, arguments: dict[str, Any]) -> ToolResult
+```
+
+Execute the HTTP request using RequestDirector for simplified parameter handling.
+
+
+### `OpenAPIResource`
+
+
+Resource implementation for OpenAPI endpoints.
+
+
+**Methods:**
+
+#### `read`
+
+```python
+read(self) -> str | bytes
+```
+
+Fetch the resource data by making an HTTP request.
+
+
+### `OpenAPIResourceTemplate`
+
+
+Resource template implementation for OpenAPI endpoints.
+
+
+**Methods:**
+
+#### `create_resource`
+
+```python
+create_resource(self, uri: str, params: dict[str, Any], context: 'Context | None' = None) -> Resource
+```
+
+Create a resource with the given parameters.
+
diff --git a/docs/python-sdk/fastmcp-server-openapi-routing.mdx b/docs/python-sdk/fastmcp-server-openapi-routing.mdx
new file mode 100644
index 000000000..cbe4b7ce1
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-openapi-routing.mdx
@@ -0,0 +1,23 @@
+---
+title: routing
+sidebarTitle: routing
+---
+
+# `fastmcp.server.openapi.routing`
+
+
+Route mapping logic for OpenAPI operations.
+
+## Classes
+
+### `MCPType`
+
+
+Type of FastMCP component to create from a route.
+
+
+### `RouteMap`
+
+
+Mapping configuration for HTTP routes to FastMCP component types.
+
diff --git a/docs/python-sdk/fastmcp-server-openapi-server.mdx b/docs/python-sdk/fastmcp-server-openapi-server.mdx
new file mode 100644
index 000000000..77a452f9f
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-openapi-server.mdx
@@ -0,0 +1,20 @@
+---
+title: server
+sidebarTitle: server
+---
+
+# `fastmcp.server.openapi.server`
+
+
+FastMCP server implementation for OpenAPI integration.
+
+## Classes
+
+### `FastMCPOpenAPI`
+
+
+FastMCP server implementation that creates components from an OpenAPI schema.
+
+This class parses an OpenAPI specification and creates appropriate FastMCP components
+(Tools, Resources, ResourceTemplates) based on route mappings.
+
diff --git a/docs/python-sdk/fastmcp-server-openapi.mdx b/docs/python-sdk/fastmcp-server-openapi.mdx
deleted file mode 100644
index 536a4489f..000000000
--- a/docs/python-sdk/fastmcp-server-openapi.mdx
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: openapi
-sidebarTitle: openapi
----
-
-# `fastmcp.server.openapi`
-
-
-FastMCP server implementation for OpenAPI integration.
-
-## Classes
-
-### `MCPType`
-
-
-Type of FastMCP component to create from a route.
-
-
-### `RouteType`
-
-
-Deprecated: Use MCPType instead.
-
-This enum is kept for backward compatibility and will be removed in a future version.
-
-
-### `RouteMap`
-
-
-Mapping configuration for HTTP routes to FastMCP component types.
-
-
-### `OpenAPITool`
-
-
-Tool implementation for OpenAPI endpoints.
-
-
-**Methods:**
-
-#### `run`
-
-```python
-run(self, arguments: dict[str, Any]) -> ToolResult
-```
-
-Execute the HTTP request based on the route configuration.
-
-
-### `OpenAPIResource`
-
-
-Resource implementation for OpenAPI endpoints.
-
-
-**Methods:**
-
-#### `read`
-
-```python
-read(self) -> str | bytes
-```
-
-Fetch the resource data by making an HTTP request.
-
-
-### `OpenAPIResourceTemplate`
-
-
-Resource template implementation for OpenAPI endpoints.
-
-
-**Methods:**
-
-#### `create_resource`
-
-```python
-create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource
-```
-
-Create a resource with the given parameters.
-
-
-### `FastMCPOpenAPI`
-
-
-FastMCP server implementation that creates components from an OpenAPI schema.
-
-This class parses an OpenAPI specification and creates appropriate FastMCP components
-(Tools, Resources, ResourceTemplates) based on route mappings.
-
diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx
index 186377e35..79ac12f81 100644
--- a/docs/python-sdk/fastmcp-server-server.mdx
+++ b/docs/python-sdk/fastmcp-server-server.mdx
@@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers.
## Functions
-### `default_lifespan`
+### `default_lifespan`
```python
default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]
@@ -26,7 +26,7 @@ Default lifespan context manager that does nothing.
- An empty dictionary as the lifespan result.
-### `add_resource_prefix`
+### `add_resource_prefix`
```python
add_resource_prefix(uri: str, prefix: str) -> str
@@ -58,7 +58,7 @@ add_resource_prefix("resource:///absolute/path", "prefix")
- `ValueError`: If the URI doesn't match the expected protocol\://path format
-### `remove_resource_prefix`
+### `remove_resource_prefix`
```python
remove_resource_prefix(uri: str, prefix: str) -> str
@@ -90,7 +90,7 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix")
- `ValueError`: If the URI doesn't match the expected protocol\://path format
-### `has_resource_prefix`
+### `has_resource_prefix`
```python
has_resource_prefix(uri: str, prefix: str) -> bool
@@ -124,53 +124,53 @@ False
## Classes
-### `FastMCP`
+### `FastMCP`
**Methods:**
-#### `settings`
+#### `settings`
```python
settings(self) -> Settings
```
-#### `name`
+#### `name`
```python
name(self) -> str
```
-#### `instructions`
+#### `instructions`
```python
instructions(self) -> str | None
```
-#### `instructions`
+#### `instructions`
```python
instructions(self, value: str | None) -> None
```
-#### `version`
+#### `version`
```python
version(self) -> str | None
```
-#### `website_url`
+#### `website_url`
```python
website_url(self) -> str | None
```
-#### `icons`
+#### `icons`
```python
icons(self) -> list[mcp.types.Icon]
```
-#### `run_async`
+#### `run_async`
```python
run_async(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None
@@ -182,7 +182,7 @@ Run the FastMCP server asynchronously.
- `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http")
-#### `run`
+#### `run`
```python
run(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None
@@ -194,13 +194,13 @@ Run the FastMCP server. Note this is a synchronous function.
- `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http")
-#### `add_middleware`
+#### `add_middleware`
```python
add_middleware(self, middleware: Middleware) -> None
```
-#### `get_tools`
+#### `get_tools`
```python
get_tools(self) -> dict[str, Tool]
@@ -209,13 +209,13 @@ get_tools(self) -> dict[str, Tool]
Get all tools (unfiltered), including mounted servers, indexed by key.
-#### `get_tool`
+#### `get_tool`
```python
get_tool(self, key: str) -> Tool
```
-#### `get_resources`
+#### `get_resources`
```python
get_resources(self) -> dict[str, Resource]
@@ -224,13 +224,13 @@ get_resources(self) -> dict[str, Resource]
Get all resources (unfiltered), including mounted servers, indexed by key.
-#### `get_resource`
+#### `get_resource`
```python
get_resource(self, key: str) -> Resource
```
-#### `get_resource_templates`
+#### `get_resource_templates`
```python
get_resource_templates(self) -> dict[str, ResourceTemplate]
@@ -239,7 +239,7 @@ get_resource_templates(self) -> dict[str, ResourceTemplate]
Get all resource templates (unfiltered), including mounted servers, indexed by key.
-#### `get_resource_template`
+#### `get_resource_template`
```python
get_resource_template(self, key: str) -> ResourceTemplate
@@ -248,7 +248,7 @@ get_resource_template(self, key: str) -> ResourceTemplate
Get a registered resource template by key.
-#### `get_prompts`
+#### `get_prompts`
```python
get_prompts(self) -> dict[str, Prompt]
@@ -257,13 +257,13 @@ get_prompts(self) -> dict[str, Prompt]
Get all prompts (unfiltered), including mounted servers, indexed by key.
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self, key: str) -> Prompt
```
-#### `custom_route`
+#### `custom_route`
```python
custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]]
@@ -284,7 +284,7 @@ Starlette's reverse URL lookup feature)
- `include_in_schema`: Whether to include in OpenAPI schema, defaults to True
-#### `add_tool`
+#### `add_tool`
```python
add_tool(self, tool: Tool) -> Tool
@@ -302,7 +302,7 @@ with the Context type annotation. See the @tool decorator for examples.
- The tool instance that was added to the server.
-#### `remove_tool`
+#### `remove_tool`
```python
remove_tool(self, name: str) -> None
@@ -317,7 +317,7 @@ Remove a tool from the server.
- `NotFoundError`: If the tool is not found
-#### `add_tool_transformation`
+#### `add_tool_transformation`
```python
add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None
@@ -326,7 +326,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi
Add a tool transformation.
-#### `remove_tool_transformation`
+#### `remove_tool_transformation`
```python
remove_tool_transformation(self, tool_name: str) -> None
@@ -335,19 +335,19 @@ remove_tool_transformation(self, tool_name: str) -> None
Remove a tool transformation.
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: AnyFunction) -> FunctionTool
```
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool]
```
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool
@@ -405,7 +405,7 @@ server.tool(my_function, name="custom_name")
```
-#### `add_resource`
+#### `add_resource`
```python
add_resource(self, resource: Resource) -> Resource
@@ -420,7 +420,7 @@ Add a resource to the server.
- The resource instance that was added to the server.
-#### `add_template`
+#### `add_template`
```python
add_template(self, template: ResourceTemplate) -> ResourceTemplate
@@ -435,7 +435,7 @@ Add a resource template to the server.
- The template instance that was added to the server.
-#### `resource`
+#### `resource`
```python
resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate]
@@ -495,7 +495,7 @@ async def get_weather(city: str) -> str:
```
-#### `add_prompt`
+#### `add_prompt`
```python
add_prompt(self, prompt: Prompt) -> Prompt
@@ -510,19 +510,19 @@ Add a prompt to the server.
- The prompt instance that was added to the server.
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt
```
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt]
```
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt
@@ -600,7 +600,7 @@ Decorator to register a prompt.
```
-#### `run_stdio_async`
+#### `run_stdio_async`
```python
run_stdio_async(self, show_banner: bool = True, log_level: str | None = None) -> None
@@ -613,7 +613,7 @@ Run the server using stdio transport.
- `log_level`: Log level for the server
-#### `run_http_async`
+#### `run_http_async`
```python
run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None) -> None
@@ -633,7 +633,7 @@ Run the server using HTTP transport.
- `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http)
-#### `http_app`
+#### `http_app`
```python
http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan
@@ -650,7 +650,7 @@ Create a Starlette app using the specified HTTP transport.
- A Starlette application configured with the specified transport
-#### `mount`
+#### `mount`
```python
mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None
@@ -704,7 +704,7 @@ automatically determined based on whether the server has a custom lifespan
- `prompt_separator`: Deprecated. Separator character for prompt names.
-#### `import_server`
+#### `import_server`
```python
import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None
@@ -745,25 +745,25 @@ applied using the protocol\://prefix/path format
- `prompt_separator`: Deprecated. Separator for prompt names.
-#### `from_openapi`
+#### `from_openapi`
```python
-from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew
+from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI
```
Create a FastMCP server from an OpenAPI specification.
-#### `from_fastapi`
+#### `from_fastapi`
```python
-from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew
+from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI
```
Create a FastMCP server from a FastAPI application.
-#### `as_proxy`
+#### `as_proxy`
```python
as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy
@@ -777,10 +777,10 @@ instance or any value accepted as the `transport` argument of
`fastmcp.client.Client` constructor.
-#### `generate_name`
+#### `generate_name`
```python
generate_name(cls, name: str | None = None) -> str
```
-### `MountedServer`
+### `MountedServer`
diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx
index 716534ea1..bb11611e9 100644
--- a/docs/python-sdk/fastmcp-settings.mdx
+++ b/docs/python-sdk/fastmcp-settings.mdx
@@ -9,7 +9,7 @@ sidebarTitle: settings
### `ExperimentalSettings`
-### `Settings`
+### `Settings`
FastMCP settings.
@@ -17,7 +17,7 @@ FastMCP settings.
**Methods:**
-#### `get_setting`
+#### `get_setting`
```python
get_setting(self, attr: str) -> Any
@@ -27,7 +27,7 @@ Get a setting. If the setting contains one or more `__`, it will be
treated as a nested setting.
-#### `set_setting`
+#### `set_setting`
```python
set_setting(self, attr: str, value: Any) -> None
@@ -37,7 +37,7 @@ Set a setting. If the setting contains one or more `__`, it will be
treated as a nested setting.
-#### `settings`
+#### `settings`
```python
settings(self) -> Self
@@ -47,13 +47,13 @@ This property is for backwards compatibility with FastMCP < 2.8.0,
which accessed fastmcp.settings.settings
-#### `normalize_log_level`
+#### `normalize_log_level`
```python
normalize_log_level(cls, v)
```
-#### `server_auth_class`
+#### `server_auth_class`
```python
server_auth_class(self) -> AuthProvider | None
diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx
index fff2e4657..c6f3931a9 100644
--- a/docs/python-sdk/fastmcp-tools-tool.mdx
+++ b/docs/python-sdk/fastmcp-tools-tool.mdx
@@ -7,7 +7,7 @@ sidebarTitle: tool
## Functions
-### `default_serializer`
+### `default_serializer`
```python
default_serializer(data: Any) -> str
@@ -15,17 +15,17 @@ default_serializer(data: Any) -> str
## Classes
-### `ToolResult`
+### `ToolResult`
**Methods:**
-#### `to_mcp_result`
+#### `to_mcp_result`
```python
to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
```
-### `Tool`
+### `Tool`
Internal tool registration info.
@@ -33,19 +33,19 @@ Internal tool registration info.
**Methods:**
-#### `enable`
+#### `enable`
```python
enable(self) -> None
```
-#### `disable`
+#### `disable`
```python
disable(self) -> None
```
-#### `to_mcp_tool`
+#### `to_mcp_tool`
```python
to_mcp_tool(self, **overrides: Any) -> MCPTool
@@ -54,7 +54,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool
Convert the FastMCP tool to an MCP tool.
-#### `from_function`
+#### `from_function`
```python
from_function(fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool
@@ -63,7 +63,7 @@ from_function(fn: Callable[..., Any], name: str | None = None, title: str | None
Create a Tool from a function.
-#### `run`
+#### `run`
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@@ -78,17 +78,17 @@ implemented by subclasses.
(list of ContentBlocks, dict of structured output).
-#### `from_tool`
+#### `from_tool`
```python
from_tool(cls, tool: Tool) -> TransformedTool
```
-### `FunctionTool`
+### `FunctionTool`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, enabled: bool | None = None) -> FunctionTool
@@ -97,7 +97,7 @@ from_function(cls, fn: Callable[..., Any], name: str | None = None, title: str |
Create a Tool from a function.
-#### `run`
+#### `run`
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@@ -106,11 +106,11 @@ run(self, arguments: dict[str, Any]) -> ToolResult
Run the tool with arguments.
-### `ParsedFunction`
+### `ParsedFunction`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-__init__.mdx b/docs/python-sdk/fastmcp-utilities-openapi-__init__.mdx
new file mode 100644
index 000000000..df0331f9e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-__init__.mdx
@@ -0,0 +1,9 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.utilities.openapi`
+
+
+OpenAPI utilities for FastMCP - refactored for better maintainability.
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-director.mdx b/docs/python-sdk/fastmcp-utilities-openapi-director.mdx
new file mode 100644
index 000000000..5d3bbcfc4
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-director.mdx
@@ -0,0 +1,36 @@
+---
+title: director
+sidebarTitle: director
+---
+
+# `fastmcp.utilities.openapi.director`
+
+
+Request director using openapi-core for stateless HTTP request building.
+
+## Classes
+
+### `RequestDirector`
+
+
+Builds httpx.Request objects from HTTPRoute and arguments using openapi-core.
+
+
+**Methods:**
+
+#### `build`
+
+```python
+build(self, route: HTTPRoute, flat_args: dict[str, Any], base_url: str = 'http://localhost') -> httpx.Request
+```
+
+Constructs a final httpx.Request object, handling all OpenAPI serialization.
+
+**Args:**
+- `route`: HTTPRoute containing OpenAPI operation details
+- `flat_args`: Flattened arguments from LLM (may include suffixed parameters)
+- `base_url`: Base URL for the request
+
+**Returns:**
+- httpx.Request: Properly formatted HTTP request
+
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx b/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx
new file mode 100644
index 000000000..1b725fc5a
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx
@@ -0,0 +1,115 @@
+---
+title: formatters
+sidebarTitle: formatters
+---
+
+# `fastmcp.utilities.openapi.formatters`
+
+
+Parameter formatting functions for OpenAPI operations.
+
+## Functions
+
+### `format_array_parameter`
+
+```python
+format_array_parameter(values: list, parameter_name: str, is_query_parameter: bool = False) -> str | list
+```
+
+
+Format an array parameter according to OpenAPI specifications.
+
+**Args:**
+- `values`: List of values to format
+- `parameter_name`: Name of the parameter (for error messages)
+- `is_query_parameter`: If True, can return list for explode=True behavior
+
+**Returns:**
+- String (comma-separated) or list (for query params with explode=True)
+
+
+### `format_deep_object_parameter`
+
+```python
+format_deep_object_parameter(param_value: dict, parameter_name: str) -> dict[str, str]
+```
+
+
+Format a dictionary parameter for deep-object style serialization.
+
+According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
+object properties as separate query parameters with bracket notation.
+
+For example, `{"id": "123", "type": "user"}` becomes
+`param[id]=123¶m[type]=user`.
+
+**Args:**
+- `param_value`: Dictionary value to format
+- `parameter_name`: Name of the parameter
+
+**Returns:**
+- Dictionary with bracketed parameter names as keys
+
+
+### `generate_example_from_schema`
+
+```python
+generate_example_from_schema(schema: JsonSchema | None) -> Any
+```
+
+
+Generate a simple example value from a JSON schema dictionary.
+Very basic implementation focusing on types.
+
+
+### `format_json_for_description`
+
+```python
+format_json_for_description(data: Any, indent: int = 2) -> str
+```
+
+
+Formats Python data as a JSON string block for Markdown.
+
+
+### `format_simple_description`
+
+```python
+format_simple_description(base_description: str, parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str
+```
+
+
+Formats a simple description for MCP objects (tools, resources, prompts).
+Excludes response details, examples, and verbose status codes.
+
+**Args:**
+- `base_description`: The initial description to be formatted.
+- `parameters`: A list of parameter information.
+- `request_body`: Information about the request body.
+
+**Returns:**
+- The formatted description string with minimal details.
+
+
+### `format_description_with_responses`
+
+```python
+format_description_with_responses(base_description: str, responses: dict[str, Any], parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str
+```
+
+
+Formats the base description string with response, parameter, and request body information.
+
+**Args:**
+- `base_description`: The initial description to be formatted.
+- `responses`: A dictionary of response information, keyed by status code.
+- `parameters`: A list of parameter information,
+including path and query parameters. Each parameter includes details such as name,
+location, whether it is required, and a description.
+- `request_body`: Information about the request body,
+including its description, whether it is required, and its content schema.
+
+**Returns:**
+- The formatted description string with additional details about responses, parameters,
+- and the request body.
+
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx b/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx
new file mode 100644
index 000000000..d74b887b1
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx
@@ -0,0 +1,62 @@
+---
+title: json_schema_converter
+sidebarTitle: json_schema_converter
+---
+
+# `fastmcp.utilities.openapi.json_schema_converter`
+
+
+
+Clean OpenAPI 3.0 to JSON Schema converter for the experimental parser.
+
+This module provides a systematic approach to converting OpenAPI 3.0 schemas
+to JSON Schema, inspired by py-openapi-schema-to-json-schema but optimized
+for our specific use case.
+
+
+## Functions
+
+### `convert_openapi_schema_to_json_schema`
+
+```python
+convert_openapi_schema_to_json_schema(schema: dict[str, Any], openapi_version: str | None = None, remove_read_only: bool = False, remove_write_only: bool = False, convert_one_of_to_any_of: bool = True) -> dict[str, Any]
+```
+
+
+Convert an OpenAPI schema to JSON Schema format.
+
+This is a clean, systematic approach that:
+1. Removes OpenAPI-specific fields
+2. Converts nullable fields to type arrays (for OpenAPI 3.0 only)
+3. Converts oneOf to anyOf for overlapping union handling
+4. Recursively processes nested schemas
+5. Optionally removes readOnly/writeOnly properties
+
+**Args:**
+- `schema`: OpenAPI schema dictionary
+- `openapi_version`: OpenAPI version for optimization
+- `remove_read_only`: Whether to remove readOnly properties
+- `remove_write_only`: Whether to remove writeOnly properties
+- `convert_one_of_to_any_of`: Whether to convert oneOf to anyOf
+
+**Returns:**
+- JSON Schema-compatible dictionary
+
+
+### `convert_schema_definitions`
+
+```python
+convert_schema_definitions(schema_definitions: dict[str, Any] | None, openapi_version: str | None = None, **kwargs) -> dict[str, Any]
+```
+
+
+Convert a dictionary of OpenAPI schema definitions to JSON Schema.
+
+**Args:**
+- `schema_definitions`: Dictionary of schema definitions
+- `openapi_version`: OpenAPI version for optimization
+- `**kwargs`: Additional arguments passed to convert_openapi_schema_to_json_schema
+
+**Returns:**
+- Dictionary of converted schema definitions
+
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-models.mdx b/docs/python-sdk/fastmcp-utilities-openapi-models.mdx
new file mode 100644
index 000000000..4c2e5bf8e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-models.mdx
@@ -0,0 +1,35 @@
+---
+title: models
+sidebarTitle: models
+---
+
+# `fastmcp.utilities.openapi.models`
+
+
+Intermediate Representation (IR) models for OpenAPI operations.
+
+## Classes
+
+### `ParameterInfo`
+
+
+Represents a single parameter for an HTTP operation in our IR.
+
+
+### `RequestBodyInfo`
+
+
+Represents the request body for an HTTP operation in our IR.
+
+
+### `ResponseInfo`
+
+
+Represents response information in our IR.
+
+
+### `HTTPRoute`
+
+
+Intermediate Representation for a single OpenAPI operation.
+
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx b/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx
new file mode 100644
index 000000000..2464180bd
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-parser.mdx
@@ -0,0 +1,43 @@
+---
+title: parser
+sidebarTitle: parser
+---
+
+# `fastmcp.utilities.openapi.parser`
+
+
+OpenAPI parsing logic for converting OpenAPI specs to HTTPRoute objects.
+
+## Functions
+
+### `parse_openapi_to_http_routes`
+
+```python
+parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]
+```
+
+
+Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
+using the openapi-pydantic library.
+
+Supports both OpenAPI 3.0.x and 3.1.x versions.
+
+
+## Classes
+
+### `OpenAPIParser`
+
+
+Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1.
+
+
+**Methods:**
+
+#### `parse`
+
+```python
+parse(self) -> list[HTTPRoute]
+```
+
+Parse the OpenAPI schema into HTTP routes.
+
diff --git a/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx b/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx
new file mode 100644
index 000000000..13380787e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx
@@ -0,0 +1,43 @@
+---
+title: schemas
+sidebarTitle: schemas
+---
+
+# `fastmcp.utilities.openapi.schemas`
+
+
+Schema manipulation utilities for OpenAPI operations.
+
+## Functions
+
+### `clean_schema_for_display`
+
+```python
+clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None
+```
+
+
+Clean up a schema dictionary for display by removing internal/complex fields.
+
+
+### `extract_output_schema_from_responses`
+
+```python
+extract_output_schema_from_responses(responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None, openapi_version: str | None = None) -> dict[str, Any] | None
+```
+
+
+Extract output schema from OpenAPI responses for use as MCP tool output schema.
+
+This function finds the first successful response (200, 201, 202, 204) with a
+JSON-compatible content type and extracts its schema. If the schema is not an
+object type, it wraps it to comply with MCP requirements.
+
+**Args:**
+- `responses`: Dictionary of ResponseInfo objects keyed by status code
+- `schema_definitions`: Optional schema definitions to include in the output schema
+- `openapi_version`: OpenAPI version string, used to optimize nullable field handling
+
+**Returns:**
+- MCP-compliant output schema with potential wrapping, or None if no suitable schema found
+
diff --git a/docs/python-sdk/fastmcp-utilities-openapi.mdx b/docs/python-sdk/fastmcp-utilities-openapi.mdx
deleted file mode 100644
index 745f6fd18..000000000
--- a/docs/python-sdk/fastmcp-utilities-openapi.mdx
+++ /dev/null
@@ -1,180 +0,0 @@
----
-title: openapi
-sidebarTitle: openapi
----
-
-# `fastmcp.utilities.openapi`
-
-## Functions
-
-### `format_array_parameter`
-
-```python
-format_array_parameter(values: list, parameter_name: str, is_query_parameter: bool = False) -> str | list
-```
-
-
-Format an array parameter according to OpenAPI specifications.
-
-**Args:**
-- `values`: List of values to format
-- `parameter_name`: Name of the parameter (for error messages)
-- `is_query_parameter`: If True, can return list for explode=True behavior
-
-**Returns:**
-- String (comma-separated) or list (for query params with explode=True)
-
-
-### `format_deep_object_parameter`
-
-```python
-format_deep_object_parameter(param_value: dict, parameter_name: str) -> dict[str, str]
-```
-
-
-Format a dictionary parameter for deepObject style serialization.
-
-According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
-object properties as separate query parameters with bracket notation.
-
-For example: `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`.
-
-**Args:**
-- `param_value`: Dictionary value to format
-- `parameter_name`: Name of the parameter
-
-**Returns:**
-- Dictionary with bracketed parameter names as keys
-
-
-### `parse_openapi_to_http_routes`
-
-```python
-parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]
-```
-
-
-Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
-using the openapi-pydantic library.
-
-Supports both OpenAPI 3.0.x and 3.1.x versions.
-
-
-### `clean_schema_for_display`
-
-```python
-clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None
-```
-
-
-Clean up a schema dictionary for display by removing internal/complex fields.
-
-
-### `generate_example_from_schema`
-
-```python
-generate_example_from_schema(schema: JsonSchema | None) -> Any
-```
-
-
-Generate a simple example value from a JSON schema dictionary.
-Very basic implementation focusing on types.
-
-
-### `format_json_for_description`
-
-```python
-format_json_for_description(data: Any, indent: int = 2) -> str
-```
-
-
-Formats Python data as a JSON string block for markdown.
-
-
-### `format_description_with_responses`
-
-```python
-format_description_with_responses(base_description: str, responses: dict[str, Any], parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str
-```
-
-
-Formats the base description string with response, parameter, and request body information.
-
-**Args:**
-- `base_description`: The initial description to be formatted.
-- `responses`: A dictionary of response information, keyed by status code.
-- `parameters`: A list of parameter information,
-including path and query parameters. Each parameter includes details such as name,
-location, whether it is required, and a description.
-- `request_body`: Information about the request body,
-including its description, whether it is required, and its content schema.
-
-**Returns:**
-- The formatted description string with additional details about responses, parameters,
-- and the request body.
-
-
-### `extract_output_schema_from_responses`
-
-```python
-extract_output_schema_from_responses(responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None, openapi_version: str | None = None) -> dict[str, Any] | None
-```
-
-
-Extract output schema from OpenAPI responses for use as MCP tool output schema.
-
-This function finds the first successful response (200, 201, 202, 204) with a
-JSON-compatible content type and extracts its schema. If the schema is not an
-object type, it wraps it to comply with MCP requirements.
-
-**Args:**
-- `responses`: Dictionary of ResponseInfo objects keyed by status code
-- `schema_definitions`: Optional schema definitions to include in the output schema
-- `openapi_version`: OpenAPI version string, used to optimize nullable field handling
-
-**Returns:**
-- MCP-compliant output schema with potential wrapping, or None if no suitable schema found
-
-
-## Classes
-
-### `ParameterInfo`
-
-
-Represents a single parameter for an HTTP operation in our IR.
-
-
-### `RequestBodyInfo`
-
-
-Represents the request body for an HTTP operation in our IR.
-
-
-### `ResponseInfo`
-
-
-Represents response information in our IR.
-
-
-### `HTTPRoute`
-
-
-Intermediate Representation for a single OpenAPI operation.
-
-
-### `OpenAPIParser`
-
-
-Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1.
-
-
-**Methods:**
-
-#### `parse`
-
-```python
-parse(self) -> list[HTTPRoute]
-```
-
-Parse the OpenAPI schema into HTTP routes.
-
diff --git a/src/fastmcp/experimental/server/openapi/__init__.py b/src/fastmcp/experimental/server/openapi/__init__.py
index cff036339..2616d1906 100644
--- a/src/fastmcp/experimental/server/openapi/__init__.py
+++ b/src/fastmcp/experimental/server/openapi/__init__.py
@@ -1,26 +1,28 @@
-"""OpenAPI server implementation for FastMCP - refactored for better maintainability."""
+"""Deprecated: Import from fastmcp.server.openapi instead."""
-# Import from server
-from .server import FastMCPOpenAPI
+import warnings
-# Import from routing
-from .routing import (
- MCPType,
- RouteMap,
- RouteMapFn,
+from fastmcp.server.openapi import (
ComponentFn,
DEFAULT_ROUTE_MAPPINGS,
+ FastMCPOpenAPI,
+ MCPType,
+ OpenAPIResource,
+ OpenAPIResourceTemplate,
+ OpenAPITool,
+ RouteMap,
+ RouteMapFn,
_determine_route_type,
)
-# Import from components
-from .components import (
- OpenAPITool,
- OpenAPIResource,
- OpenAPIResourceTemplate,
+# Deprecated in 2.14 when OpenAPI support was promoted out of experimental
+warnings.warn(
+ "Importing from fastmcp.experimental.server.openapi is deprecated. "
+ "Import from fastmcp.server.openapi instead.",
+ DeprecationWarning,
+ stacklevel=2,
)
-# Export public symbols - maintaining backward compatibility
__all__ = [
"DEFAULT_ROUTE_MAPPINGS",
"ComponentFn",
diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py
index f71bc7a6a..51c947bf1 100644
--- a/src/fastmcp/experimental/utilities/openapi/__init__.py
+++ b/src/fastmcp/experimental/utilities/openapi/__init__.py
@@ -1,63 +1,37 @@
-"""OpenAPI utilities for FastMCP - refactored for better maintainability."""
+"""Deprecated: Import from fastmcp.utilities.openapi instead."""
-# Import from models
-from .models import (
+import warnings
+
+from fastmcp.utilities.openapi import (
HTTPRoute,
HttpMethod,
- JsonSchema,
ParameterInfo,
ParameterLocation,
RequestBodyInfo,
ResponseInfo,
-)
-
-# Import from parser
-from .parser import parse_openapi_to_http_routes
-
-# Import from formatters
-from .formatters import (
- format_array_parameter,
- format_deep_object_parameter,
- format_description_with_responses,
- format_json_for_description,
- format_simple_description,
- generate_example_from_schema,
-)
-
-# Import from schemas
-from .schemas import (
- _combine_schemas,
extract_output_schema_from_responses,
- clean_schema_for_display,
- _make_optional_parameter_nullable,
+ format_simple_description,
+ parse_openapi_to_http_routes,
+ _combine_schemas,
)
-# Import from json_schema_converter
-from .json_schema_converter import (
- convert_openapi_schema_to_json_schema,
- convert_schema_definitions,
+# Deprecated in 2.14 when OpenAPI support was promoted out of experimental
+warnings.warn(
+ "Importing from fastmcp.experimental.utilities.openapi is deprecated. "
+ "Import from fastmcp.utilities.openapi instead.",
+ DeprecationWarning,
+ stacklevel=2,
)
-# Export public symbols - maintaining backward compatibility
__all__ = [
"HTTPRoute",
"HttpMethod",
- "JsonSchema",
"ParameterInfo",
"ParameterLocation",
"RequestBodyInfo",
"ResponseInfo",
"_combine_schemas",
- "_make_optional_parameter_nullable",
- "clean_schema_for_display",
- "convert_openapi_schema_to_json_schema",
- "convert_schema_definitions",
"extract_output_schema_from_responses",
- "format_array_parameter",
- "format_deep_object_parameter",
- "format_description_with_responses",
- "format_json_for_description",
"format_simple_description",
- "generate_example_from_schema",
"parse_openapi_to_http_routes",
]
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index a7acf1368..3f1c18fc3 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -438,7 +438,7 @@ def create_error_html(
Args:
error_title: The error title (e.g., "OAuth Error", "Authorization Failed")
error_message: The main error message to display
- error_details: Optional dictionary of error details to show (e.g., {"Error Code": "invalid_client"})
+ error_details: Optional dictionary of error details to show (e.g., `{"Error Code": "invalid_client"}`)
server_name: Optional server name to display
server_icon_url: Optional URL to server icon/logo
diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py
deleted file mode 100644
index e23cdde49..000000000
--- a/src/fastmcp/server/openapi.py
+++ /dev/null
@@ -1,1087 +0,0 @@
-"""FastMCP server implementation for OpenAPI integration."""
-
-from __future__ import annotations
-
-import enum
-import json
-import re
-import warnings
-from collections import Counter
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from re import Pattern
-from typing import TYPE_CHECKING, Any, Literal
-
-import httpx
-from mcp.types import ToolAnnotations
-from pydantic.networks import AnyUrl
-
-import fastmcp
-from fastmcp.exceptions import ToolError
-from fastmcp.resources import Resource, ResourceTemplate
-from fastmcp.server.dependencies import get_http_headers
-from fastmcp.server.server import FastMCP
-from fastmcp.tools.tool import Tool, ToolResult
-from fastmcp.utilities import openapi
-from fastmcp.utilities.logging import get_logger
-from fastmcp.utilities.openapi import (
- HTTPRoute,
- _combine_schemas,
- extract_output_schema_from_responses,
- format_array_parameter,
- format_deep_object_parameter,
- format_description_with_responses,
-)
-
-if TYPE_CHECKING:
- from fastmcp.server import Context
-
-logger = get_logger(__name__)
-
-HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
-
-
-def _slugify(text: str) -> str:
- """
- Convert text to a URL-friendly slug format that only contains lowercase
- letters, uppercase letters, numbers, and underscores.
- """
- if not text:
- return ""
-
- # Replace spaces and common separators with underscores
- slug = re.sub(r"[\s\-\.]+", "_", text)
-
- # Remove non-alphanumeric characters except underscores
- slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
-
- # Remove multiple consecutive underscores
- slug = re.sub(r"_+", "_", slug)
-
- # Remove leading/trailing underscores
- slug = slug.strip("_")
-
- return slug
-
-
-# Type definitions for the mapping functions
-RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
-ComponentFn = Callable[
- [
- HTTPRoute,
- "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
- ],
- None,
-]
-
-
-class MCPType(enum.Enum):
- """Type of FastMCP component to create from a route.
-
- Enum values:
- TOOL: Convert the route to a callable Tool
- RESOURCE: Convert the route to a Resource (typically GET endpoints)
- RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
- EXCLUDE: Exclude the route from being converted to any MCP component
- IGNORE: Deprecated, use EXCLUDE instead
- """
-
- TOOL = "TOOL"
- RESOURCE = "RESOURCE"
- RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
- # PROMPT = "PROMPT"
- EXCLUDE = "EXCLUDE"
-
-
-# Keep RouteType as an alias to MCPType for backward compatibility
-class RouteType(enum.Enum):
- """
- Deprecated: Use MCPType instead.
-
- This enum is kept for backward compatibility and will be removed in a future version.
- """
-
- TOOL = "TOOL"
- RESOURCE = "RESOURCE"
- RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
- IGNORE = "IGNORE"
-
-
-@dataclass(kw_only=True)
-class RouteMap:
- """Mapping configuration for HTTP routes to FastMCP component types."""
-
- methods: list[HttpMethod] | Literal["*"] = field(default="*")
- pattern: Pattern[str] | str = field(default=r".*")
- route_type: RouteType | MCPType | None = field(default=None)
- tags: set[str] = field(
- default_factory=set,
- metadata={"description": "A set of tags to match. All tags must match."},
- )
- mcp_type: MCPType | None = field(
- default=None,
- metadata={"description": "The type of FastMCP component to create."},
- )
- mcp_tags: set[str] = field(
- default_factory=set,
- metadata={
- "description": "A set of tags to apply to the generated FastMCP component."
- },
- )
-
- def __post_init__(self):
- """Validate and process the route map after initialization."""
- # Handle backward compatibility for route_type, deprecated in 2.5.0
- if self.mcp_type is None and self.route_type is not None:
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- "The 'route_type' parameter is deprecated and will be removed in a future version. "
- "Use 'mcp_type' instead with the appropriate MCPType value.",
- DeprecationWarning,
- stacklevel=2,
- )
- if isinstance(self.route_type, RouteType):
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- "The RouteType class is deprecated and will be removed in a future version. "
- "Use MCPType instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- # Check for the deprecated IGNORE value
- if self.route_type == RouteType.IGNORE:
- if fastmcp.settings.deprecation_warnings:
- warnings.warn(
- "RouteType.IGNORE is deprecated and will be removed in a future version. "
- "Use MCPType.EXCLUDE instead.",
- DeprecationWarning,
- stacklevel=2,
- )
-
- # Convert from RouteType to MCPType if needed
- if isinstance(self.route_type, RouteType):
- route_type_name = self.route_type.name
- if route_type_name == "IGNORE":
- route_type_name = "EXCLUDE"
- self.mcp_type = getattr(MCPType, route_type_name)
- else:
- self.mcp_type = self.route_type
- elif self.mcp_type is None:
- raise ValueError("`mcp_type` must be provided")
-
- # Set route_type to match mcp_type for backward compatibility
- if self.route_type is None:
- self.route_type = self.mcp_type
-
-
-# Default route mapping: all routes become tools.
-# Users can provide custom route_maps to override this behavior.
-DEFAULT_ROUTE_MAPPINGS = [
- RouteMap(mcp_type=MCPType.TOOL),
-]
-
-
-def _determine_route_type(
- route: openapi.HTTPRoute,
- mappings: list[RouteMap],
-) -> RouteMap:
- """
- Determines the FastMCP component type based on the route and mappings.
-
- Args:
- route: HTTPRoute object
- mappings: List of RouteMap objects in priority order
-
- Returns:
- The RouteMap that matches the route, or a catchall "Tool" RouteMap if no match is found.
- """
- # Check mappings in priority order (first match wins)
- for route_map in mappings:
- # Check if the HTTP method matches
- if route_map.methods == "*" or route.method in route_map.methods:
- # Handle both string patterns and compiled Pattern objects
- if isinstance(route_map.pattern, Pattern):
- pattern_matches = route_map.pattern.search(route.path)
- else:
- pattern_matches = re.search(route_map.pattern, route.path)
-
- if pattern_matches:
- # Check if tags match (if specified)
- # If route_map.tags is empty, tags are not matched
- # If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
- if route_map.tags:
- route_tags_set = set(route.tags or [])
- if not route_map.tags.issubset(route_tags_set):
- # Tags don't match, continue to next mapping
- continue
-
- # We know mcp_type is not None here due to post_init validation
- assert route_map.mcp_type is not None
- logger.debug(
- f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
- )
- return route_map
-
- # Default fallback
- return RouteMap(mcp_type=MCPType.TOOL)
-
-
-class OpenAPITool(Tool):
- """Tool implementation for OpenAPI endpoints."""
-
- def __init__(
- self,
- client: httpx.AsyncClient,
- route: openapi.HTTPRoute,
- name: str,
- description: str,
- parameters: dict[str, Any],
- output_schema: dict[str, Any] | None = None,
- tags: set[str] | None = None,
- timeout: float | None = None,
- annotations: ToolAnnotations | None = None,
- serializer: Callable[[Any], str] | None = None,
- ):
- super().__init__(
- name=name,
- description=description,
- parameters=parameters,
- output_schema=output_schema,
- tags=tags or set(),
- annotations=annotations,
- serializer=serializer,
- )
- self._client = client
- self._route = route
- self._timeout = timeout
-
- def __repr__(self) -> str:
- """Custom representation to prevent recursion errors when printing."""
- return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
-
- async def run(self, arguments: dict[str, Any]) -> ToolResult:
- """Execute the HTTP request based on the route configuration."""
-
- # Create mapping from suffixed parameter names back to original names and locations
- # This handles parameter collisions where suffixes were added during schema generation
- param_mapping = {} # suffixed_name -> (original_name, location)
-
- # First, check if we have request body properties to detect collisions
- body_props = set()
- if self._route.request_body and self._route.request_body.content_schema:
- content_type = next(iter(self._route.request_body.content_schema))
- body_schema = self._route.request_body.content_schema[content_type]
- body_props = set(body_schema.get("properties", {}).keys())
-
- # Build parameter mapping for potentially suffixed parameters
- for param in self._route.parameters:
- original_name = param.name
- suffixed_name = f"{param.name}__{param.location}"
-
- # If parameter name collides with body property, it would have been suffixed
- if param.name in body_props:
- param_mapping[suffixed_name] = (original_name, param.location)
- # Also map original name for backward compatibility when no collision
- param_mapping[original_name] = (original_name, param.location)
-
- # Prepare URL
- path = self._route.path
-
- # Replace path parameters with values from arguments
- # Look for both original and suffixed parameter names
- path_params = {}
- for p in self._route.parameters:
- if p.location == "path":
- # Try suffixed name first, then original name
- suffixed_name = f"{p.name}__{p.location}"
- if (
- suffixed_name in arguments
- and arguments.get(suffixed_name) is not None
- ):
- path_params[p.name] = arguments[suffixed_name]
- elif p.name in arguments and arguments.get(p.name) is not None:
- path_params[p.name] = arguments[p.name]
-
- # Ensure all path parameters are provided
- required_path_params = {
- p.name
- for p in self._route.parameters
- if p.location == "path" and p.required
- }
- missing_params = required_path_params - path_params.keys()
- if missing_params:
- raise ToolError(f"Missing required path parameters: {missing_params}")
-
- for param_name, param_value in path_params.items():
- # Handle array path parameters with style 'simple' (comma-separated)
- # In OpenAPI, 'simple' is the default style for path parameters
- param_info = next(
- (p for p in self._route.parameters if p.name == param_name), None
- )
-
- if param_info and isinstance(param_value, list):
- # Check if schema indicates an array type
- schema = param_info.schema_
- is_array = schema.get("type") == "array"
-
- if is_array:
- # Format array values as comma-separated string
- # This follows the OpenAPI 'simple' style (default for path)
- formatted_value = format_array_parameter(
- param_value, param_name, is_query_parameter=False
- )
- path = path.replace(f"{{{param_name}}}", str(formatted_value))
- continue
-
- # Default handling for non-array parameters or non-array schemas
- path = path.replace(f"{{{param_name}}}", str(param_value))
-
- # Prepare query parameters - filter out None and empty strings
- query_params = {}
- for p in self._route.parameters:
- if p.location == "query":
- # Try suffixed name first, then original name
- suffixed_name = f"{p.name}__{p.location}"
- param_value = None
-
- suffixed_value = arguments.get(suffixed_name)
- if (
- suffixed_name in arguments
- and suffixed_value is not None
- and suffixed_value != ""
- and not (
- isinstance(suffixed_value, list | dict)
- and len(suffixed_value) == 0
- )
- ):
- param_value = arguments[suffixed_name]
- else:
- name_value = arguments.get(p.name)
- if (
- p.name in arguments
- and name_value is not None
- and name_value != ""
- and not (
- isinstance(name_value, list | dict) and len(name_value) == 0
- )
- ):
- param_value = arguments[p.name]
-
- if param_value is not None:
- # Handle different parameter styles and types
- param_style = (
- p.style or "form"
- ) # Default style for query parameters is "form"
- param_explode = (
- p.explode if p.explode is not None else True
- ) # Default explode for query is True
-
- # Handle deepObject style for object parameters
- if (
- param_style == "deepObject"
- and isinstance(param_value, dict)
- and len(param_value) > 0
- ):
- if param_explode:
- # deepObject with explode=true: object properties become separate parameters
- # e.g., target[id]=123&target[type]=user
- deep_obj_params = format_deep_object_parameter(
- param_value, p.name
- )
- query_params.update(deep_obj_params)
- else:
- # deepObject with explode=false is not commonly used, fallback to JSON
- logger.warning(
- f"deepObject style with explode=false for parameter '{p.name}' is not standard. "
- f"Using JSON serialization fallback."
- )
- query_params[p.name] = json.dumps(param_value)
- # Handle array parameters with form style (default)
- elif (
- isinstance(param_value, list)
- and p.schema_.get("type") == "array"
- and len(param_value) > 0
- ):
- if param_explode:
- # When explode=True, we pass the array directly, which HTTPX will serialize
- # as multiple parameters with the same name
- query_params[p.name] = param_value
- else:
- # Format array as comma-separated string when explode=False
- formatted_value = format_array_parameter(
- param_value, p.name, is_query_parameter=True
- )
- query_params[p.name] = formatted_value
- else:
- # Non-array, non-deepObject parameters are passed as is
- query_params[p.name] = param_value
-
- # Prepare headers - fix typing by ensuring all values are strings
- headers = {}
-
- # Start with OpenAPI-defined header parameters
- openapi_headers = {}
- for p in self._route.parameters:
- if p.location == "header":
- # Try suffixed name first, then original name
- suffixed_name = f"{p.name}__{p.location}"
- param_value = None
-
- if (
- suffixed_name in arguments
- and arguments.get(suffixed_name) is not None
- ):
- param_value = arguments[suffixed_name]
- elif p.name in arguments and arguments.get(p.name) is not None:
- param_value = arguments[p.name]
-
- if param_value is not None:
- openapi_headers[p.name.lower()] = str(param_value)
- headers.update(openapi_headers)
-
- # Add headers from the current MCP client HTTP request (these take precedence)
- mcp_headers = get_http_headers()
- headers.update(mcp_headers)
-
- # Prepare request body
- json_data = None
- if self._route.request_body and self._route.request_body.content_schema:
- # Extract body parameters with collision-aware logic
- # Exclude all parameter names that belong to path/query/header locations
- params_to_exclude = set()
-
- for p in self._route.parameters:
- if (
- p.name in body_props
- ): # This parameter had a collision, so it was suffixed
- params_to_exclude.add(f"{p.name}__{p.location}")
- else: # No collision, parameter keeps original name but should still be excluded from body
- params_to_exclude.add(p.name)
-
- body_params = {
- k: v for k, v in arguments.items() if k not in params_to_exclude
- }
-
- if body_params:
- json_data = body_params
-
- # Execute the request
- try:
- response = await self._client.request(
- method=self._route.method,
- url=path,
- params=query_params,
- headers=headers,
- json=json_data,
- timeout=self._timeout,
- )
-
- # Raise for 4xx/5xx responses
- response.raise_for_status()
-
- # Try to parse as JSON first
- try:
- result = response.json()
-
- # Handle structured content based on output schema, if any
- structured_output = None
- if self.output_schema is not None:
- if self.output_schema.get("x-fastmcp-wrap-result"):
- # Schema says wrap - always wrap in result key
- structured_output = {"result": result}
- else:
- structured_output = result
- # If no output schema, use fallback logic for backward compatibility
- elif not isinstance(result, dict):
- structured_output = {"result": result}
- else:
- structured_output = result
-
- return ToolResult(structured_content=structured_output)
- except json.JSONDecodeError:
- return ToolResult(content=response.text)
-
- except httpx.HTTPStatusError as e:
- # Handle HTTP errors (4xx, 5xx)
- error_message = (
- f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
- )
- try:
- error_data = e.response.json()
- error_message += f" - {error_data}"
- except (json.JSONDecodeError, ValueError):
- if e.response.text:
- error_message += f" - {e.response.text}"
-
- raise ValueError(error_message) from e
-
- except httpx.RequestError as e:
- # Handle request errors (connection, timeout, etc.)
- raise ValueError(f"Request error: {e!s}") from e
-
-
-class OpenAPIResource(Resource):
- """Resource implementation for OpenAPI endpoints."""
-
- def __init__(
- self,
- client: httpx.AsyncClient,
- route: openapi.HTTPRoute,
- uri: str,
- name: str,
- description: str,
- mime_type: str = "application/json",
- tags: set[str] | None = None,
- timeout: float | None = None,
- ):
- if tags is None:
- tags = set()
- super().__init__(
- uri=AnyUrl(uri), # Convert string to AnyUrl
- name=name,
- description=description,
- mime_type=mime_type,
- tags=tags,
- )
- self._client = client
- self._route = route
- self._timeout = timeout
-
- def __repr__(self) -> str:
- """Custom representation to prevent recursion errors when printing."""
- return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
-
- async def read(self) -> str | bytes:
- """Fetch the resource data by making an HTTP request."""
- try:
- # Extract path parameters from the URI if present
- path = self._route.path
- resource_uri = str(self.uri)
-
- # If this is a templated resource, extract path parameters from the URI
- if "{" in path and "}" in path:
- # Extract the resource ID from the URI (the last part after the last slash)
- parts = resource_uri.split("/")
-
- if len(parts) > 1:
- # Find all path parameters in the route path
- path_params = {}
-
- # Find the path parameter names from the route path
- param_matches = re.findall(r"\{([^}]+)\}", path)
- if param_matches:
- # Reverse sorting from creation order (traversal is backwards)
- param_matches.sort(reverse=True)
- # Number of sent parameters is number of parts -1 (assuming first part is resource identifier)
- expected_param_count = len(parts) - 1
- # Map parameters from the end of the URI to the parameters in the path
- # Last parameter in URI (parts[-1]) maps to last parameter in path, and so on
- for i, param_name in enumerate(param_matches):
- # Ensure we don't use resource identifier as parameter
- if i < expected_param_count:
- # Get values from the end of parts
- param_value = parts[-1 - i]
- path_params[param_name] = param_value
-
- # Replace path parameters with their values
- for param_name, param_value in path_params.items():
- path = path.replace(f"{{{param_name}}}", str(param_value))
-
- # Filter any query parameters - get query parameters and filter out None/empty values
- query_params = {}
- for param in self._route.parameters:
- if param.location == "query" and hasattr(self, f"_{param.name}"):
- value = getattr(self, f"_{param.name}")
- if value is not None and value != "":
- query_params[param.name] = value
-
- # Prepare headers from MCP client request if available
- headers = {}
- mcp_headers = get_http_headers()
- headers.update(mcp_headers)
-
- response = await self._client.request(
- method=self._route.method,
- url=path,
- params=query_params,
- headers=headers,
- timeout=self._timeout,
- )
-
- # Raise for 4xx/5xx responses
- response.raise_for_status()
-
- # Determine content type and return appropriate format
- content_type = response.headers.get("content-type", "").lower()
-
- if "application/json" in content_type:
- result = response.json()
- return json.dumps(result)
- elif any(ct in content_type for ct in ["text/", "application/xml"]):
- return response.text
- else:
- return response.content
-
- except httpx.HTTPStatusError as e:
- # Handle HTTP errors (4xx, 5xx)
- error_message = (
- f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
- )
- try:
- error_data = e.response.json()
- error_message += f" - {error_data}"
- except (json.JSONDecodeError, ValueError):
- if e.response.text:
- error_message += f" - {e.response.text}"
-
- raise ValueError(error_message) from e
-
- except httpx.RequestError as e:
- # Handle request errors (connection, timeout, etc.)
- raise ValueError(f"Request error: {e!s}") from e
-
-
-class OpenAPIResourceTemplate(ResourceTemplate):
- """Resource template implementation for OpenAPI endpoints."""
-
- def __init__(
- self,
- client: httpx.AsyncClient,
- route: openapi.HTTPRoute,
- uri_template: str,
- name: str,
- description: str,
- parameters: dict[str, Any],
- tags: set[str] | None = None,
- timeout: float | None = None,
- ):
- if tags is None:
- tags = set()
- super().__init__(
- uri_template=uri_template,
- name=name,
- description=description,
- parameters=parameters,
- tags=tags,
- )
- self._client = client
- self._route = route
- self._timeout = timeout
-
- def __repr__(self) -> str:
- """Custom representation to prevent recursion errors when printing."""
- return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
-
- async def create_resource(
- self,
- uri: str,
- params: dict[str, Any],
- context: Context | None = None,
- ) -> Resource:
- """Create a resource with the given parameters."""
- # Generate a URI for this resource instance
- uri_parts = []
- for key, value in params.items():
- uri_parts.append(f"{key}={value}")
-
- # Create and return a resource
- return OpenAPIResource(
- client=self._client,
- route=self._route,
- uri=uri,
- name=f"{self.name}-{'-'.join(uri_parts)}",
- description=self.description or f"Resource for {self._route.path}",
- mime_type="application/json",
- tags=set(self._route.tags or []),
- timeout=self._timeout,
- )
-
-
-class FastMCPOpenAPI(FastMCP):
- """
- FastMCP server implementation that creates components from an OpenAPI schema.
-
- This class parses an OpenAPI specification and creates appropriate FastMCP components
- (Tools, Resources, ResourceTemplates) based on route mappings.
-
- Example:
- ```python
- from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
- import httpx
-
- # Define custom route mappings
- custom_mappings = [
- # Map all user-related endpoints to ResourceTemplate
- RouteMap(
- methods=["GET", "POST", "PATCH"],
- pattern=r".*/users/.*",
- mcp_type=MCPType.RESOURCE_TEMPLATE
- ),
- # Map all analytics endpoints to Tool
- RouteMap(
- methods=["GET"],
- pattern=r".*/analytics/.*",
- mcp_type=MCPType.TOOL
- ),
- ]
-
- # Create server with custom mappings and route mapper
- server = FastMCPOpenAPI(
- openapi_spec=spec,
- client=httpx.AsyncClient(),
- name="API Server",
- route_maps=custom_mappings,
- )
- ```
- """
-
- def __init__(
- self,
- openapi_spec: dict[str, Any],
- client: httpx.AsyncClient,
- name: str | None = None,
- route_maps: list[RouteMap] | None = None,
- route_map_fn: RouteMapFn | None = None,
- mcp_component_fn: ComponentFn | None = None,
- mcp_names: dict[str, str] | None = None,
- tags: set[str] | None = None,
- timeout: float | None = None,
- **settings: Any,
- ):
- """
- Initialize a FastMCP server from an OpenAPI schema.
-
- Args:
- openapi_spec: OpenAPI schema as a dictionary or file path
- client: httpx AsyncClient for making HTTP requests
- name: Optional name for the server
- route_maps: Optional list of RouteMap objects defining route mappings
- route_map_fn: Optional callable for advanced route type mapping.
- Receives (route, mcp_type) and returns MCPType or None.
- Called on every route, including excluded ones.
- mcp_component_fn: Optional callable for component customization.
- Receives (route, component) and can modify the component in-place.
- Called on every created component.
- mcp_names: Optional dictionary mapping operationId to desired component names.
- If an operationId is not in the dictionary, falls back to using the
- operationId up to the first double underscore. If no operationId exists,
- falls back to slugified summary or path-based naming.
- All names are truncated to 56 characters maximum.
- tags: Optional set of tags to add to all components. Components always receive any tags
- from the route.
- timeout: Optional timeout (in seconds) for all requests
- **settings: Additional settings for FastMCP
- """
- super().__init__(name=name or "OpenAPI FastMCP", **settings)
-
- self._client = client
- self._timeout = timeout
- self._mcp_component_fn = mcp_component_fn
-
- # Keep track of names to detect collisions
- self._used_names = {
- "tool": Counter(),
- "resource": Counter(),
- "resource_template": Counter(),
- "prompt": Counter(),
- }
-
- http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
-
- # Process routes
- num_excluded = 0
- route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
- for route in http_routes:
- # Determine route type based on mappings or default rules
- route_map = _determine_route_type(route, route_maps)
-
- # TODO: remove this once RouteType is removed and mcp_type is typed as MCPType without | None
- assert route_map.mcp_type is not None
- route_type = route_map.mcp_type
-
- # Call route_map_fn if provided
- if route_map_fn is not None:
- try:
- result = route_map_fn(route, route_type)
- if result is not None:
- route_type = result
- logger.debug(
- f"Route {route.method} {route.path} mapping customized by route_map_fn: "
- f"type={route_type.name}"
- )
- except Exception as e:
- logger.warning(
- f"Error in route_map_fn for {route.method} {route.path}: {e}. "
- f"Using default values."
- )
-
- # Generate a default name from the route
- component_name = self._generate_default_name(route, mcp_names)
-
- route_tags = set(route.tags) | route_map.mcp_tags | (tags or set())
-
- if route_type == MCPType.TOOL:
- self._create_openapi_tool(route, component_name, tags=route_tags)
- elif route_type == MCPType.RESOURCE:
- self._create_openapi_resource(route, component_name, tags=route_tags)
- elif route_type == MCPType.RESOURCE_TEMPLATE:
- self._create_openapi_template(route, component_name, tags=route_tags)
- elif route_type == MCPType.EXCLUDE:
- logger.info(f"Excluding route: {route.method} {route.path}")
- num_excluded += 1
-
- logger.info(
- f"Created FastMCP OpenAPI server with {len(http_routes) - num_excluded} routes"
- )
-
- def _generate_default_name(
- self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
- ) -> str:
- """Generate a default name from the route using the configured strategy."""
- name = ""
- mcp_names_map = mcp_names_map or {}
-
- # First check if there's a custom mapping for this operationId
- if route.operation_id:
- if route.operation_id in mcp_names_map:
- name = mcp_names_map[route.operation_id]
- else:
- # If there's a double underscore in the operationId, use the first part
- name = route.operation_id.split("__")[0]
- else:
- name = route.summary or f"{route.method}_{route.path}"
-
- name = _slugify(name)
-
- # Truncate to 56 characters maximum
- if len(name) > 56:
- name = name[:56]
-
- return name
-
- def _get_unique_name(
- self,
- name: str,
- component_type: Literal["tool", "resource", "resource_template", "prompt"],
- ) -> str:
- """
- Ensure the name is unique within its component type by appending numbers if needed.
-
- Args:
- name: The proposed name
- component_type: The type of component ("tools", "resources", or "templates")
-
- Returns:
- str: A unique name for the component
- """
- # Check if the name is already used
- self._used_names[component_type][name] += 1
- if self._used_names[component_type][name] == 1:
- return name
-
- else:
- # Create the new name
- new_name = f"{name}_{self._used_names[component_type][name]}"
- logger.debug(
- f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
- f"Using '{new_name}' instead."
- )
-
- return new_name
-
- def _create_openapi_tool(
- self,
- route: openapi.HTTPRoute,
- name: str,
- tags: set[str],
- ):
- """Creates and registers an OpenAPITool with enhanced description."""
- combined_schema = _combine_schemas(route)
-
- # Extract output schema from OpenAPI responses
- output_schema = extract_output_schema_from_responses(
- route.responses, route.schema_definitions, route.openapi_version
- )
-
- # Get a unique tool name
- tool_name = self._get_unique_name(name, "tool")
-
- base_description = (
- route.description
- or route.summary
- or f"Executes {route.method} {route.path}"
- )
-
- # Format enhanced description with parameters and request body
- enhanced_description = format_description_with_responses(
- base_description=base_description,
- responses=route.responses,
- parameters=route.parameters,
- request_body=route.request_body,
- )
-
- tool = OpenAPITool(
- client=self._client,
- route=route,
- name=tool_name,
- description=enhanced_description,
- parameters=combined_schema,
- output_schema=output_schema,
- tags=set(route.tags or []) | tags,
- timeout=self._timeout,
- )
-
- # Call component_fn if provided
- if self._mcp_component_fn is not None:
- try:
- self._mcp_component_fn(route, tool)
- logger.debug(f"Tool {tool_name} customized by component_fn")
- except Exception as e:
- logger.warning(
- f"Error in component_fn for tool {tool_name}: {e}. "
- f"Using component as-is."
- )
-
- # Use the potentially modified tool name as the registration key
- final_tool_name = tool.name
-
- # Register the tool by directly assigning to the tools dictionary
- self._tool_manager._tools[final_tool_name] = tool
- logger.debug(
- f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
- )
-
- def _create_openapi_resource(
- self,
- route: openapi.HTTPRoute,
- name: str,
- tags: set[str],
- ):
- """Creates and registers an OpenAPIResource with enhanced description."""
- # Get a unique resource name
- resource_name = self._get_unique_name(name, "resource")
-
- resource_uri = f"resource://{resource_name}"
- base_description = (
- route.description or route.summary or f"Represents {route.path}"
- )
-
- # Format enhanced description with parameters and request body
- enhanced_description = format_description_with_responses(
- base_description=base_description,
- responses=route.responses,
- parameters=route.parameters,
- request_body=route.request_body,
- )
-
- resource = OpenAPIResource(
- client=self._client,
- route=route,
- uri=resource_uri,
- name=resource_name,
- description=enhanced_description,
- tags=set(route.tags or []) | tags,
- timeout=self._timeout,
- )
-
- # Call component_fn if provided
- if self._mcp_component_fn is not None:
- try:
- self._mcp_component_fn(route, resource)
- logger.debug(f"Resource {resource_uri} customized by component_fn")
- except Exception as e:
- logger.warning(
- f"Error in component_fn for resource {resource_uri}: {e}. "
- f"Using component as-is."
- )
-
- # Use the potentially modified resource URI as the registration key
- final_resource_uri = str(resource.uri)
-
- # Register the resource by directly assigning to the resources dictionary
- self._resource_manager._resources[final_resource_uri] = resource
- logger.debug(
- f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
- )
-
- def _create_openapi_template(
- self,
- route: openapi.HTTPRoute,
- name: str,
- tags: set[str],
- ):
- """Creates and registers an OpenAPIResourceTemplate with enhanced description."""
- # Get a unique template name
- template_name = self._get_unique_name(name, "resource_template")
-
- path_params = [p.name for p in route.parameters if p.location == "path"]
- path_params.sort() # Sort for consistent URIs
-
- uri_template_str = f"resource://{template_name}"
- if path_params:
- uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
-
- base_description = (
- route.description or route.summary or f"Template for {route.path}"
- )
-
- # Format enhanced description with parameters and request body
- enhanced_description = format_description_with_responses(
- base_description=base_description,
- responses=route.responses,
- parameters=route.parameters,
- request_body=route.request_body,
- )
-
- template_params_schema = {
- "type": "object",
- "properties": {
- p.name: {
- **(p.schema_.copy() if isinstance(p.schema_, dict) else {}),
- **(
- {"description": p.description}
- if p.description
- and not (
- isinstance(p.schema_, dict) and "description" in p.schema_
- )
- else {}
- ),
- }
- for p in route.parameters
- if p.location == "path"
- },
- "required": [
- p.name for p in route.parameters if p.location == "path" and p.required
- ],
- }
-
- template = OpenAPIResourceTemplate(
- client=self._client,
- route=route,
- uri_template=uri_template_str,
- name=template_name,
- description=enhanced_description,
- parameters=template_params_schema,
- tags=set(route.tags or []) | tags,
- timeout=self._timeout,
- )
-
- # Call component_fn if provided
- if self._mcp_component_fn is not None:
- try:
- self._mcp_component_fn(route, template)
- logger.debug(f"Template {uri_template_str} customized by component_fn")
- except Exception as e:
- logger.warning(
- f"Error in component_fn for template {uri_template_str}: {e}. "
- f"Using component as-is."
- )
-
- # Use the potentially modified template URI as the registration key
- final_template_uri = template.uri_template
-
- # Register the template by directly assigning to the templates dictionary
- self._resource_manager._templates[final_template_uri] = template
- logger.debug(
- f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
- )
diff --git a/src/fastmcp/experimental/server/openapi/README.md b/src/fastmcp/server/openapi/README.md
similarity index 100%
rename from src/fastmcp/experimental/server/openapi/README.md
rename to src/fastmcp/server/openapi/README.md
diff --git a/src/fastmcp/server/openapi/__init__.py b/src/fastmcp/server/openapi/__init__.py
new file mode 100644
index 000000000..cff036339
--- /dev/null
+++ b/src/fastmcp/server/openapi/__init__.py
@@ -0,0 +1,35 @@
+"""OpenAPI server implementation for FastMCP - refactored for better maintainability."""
+
+# Import from server
+from .server import FastMCPOpenAPI
+
+# Import from routing
+from .routing import (
+ MCPType,
+ RouteMap,
+ RouteMapFn,
+ ComponentFn,
+ DEFAULT_ROUTE_MAPPINGS,
+ _determine_route_type,
+)
+
+# Import from components
+from .components import (
+ OpenAPITool,
+ OpenAPIResource,
+ OpenAPIResourceTemplate,
+)
+
+# Export public symbols - maintaining backward compatibility
+__all__ = [
+ "DEFAULT_ROUTE_MAPPINGS",
+ "ComponentFn",
+ "FastMCPOpenAPI",
+ "MCPType",
+ "OpenAPIResource",
+ "OpenAPIResourceTemplate",
+ "OpenAPITool",
+ "RouteMap",
+ "RouteMapFn",
+ "_determine_route_type",
+]
diff --git a/src/fastmcp/experimental/server/openapi/components.py b/src/fastmcp/server/openapi/components.py
similarity index 98%
rename from src/fastmcp/experimental/server/openapi/components.py
rename to src/fastmcp/server/openapi/components.py
index 961c6363a..40577cfa7 100644
--- a/src/fastmcp/experimental/server/openapi/components.py
+++ b/src/fastmcp/server/openapi/components.py
@@ -9,14 +9,15 @@ import httpx
from mcp.types import ToolAnnotations
from pydantic.networks import AnyUrl
-# Import from our new utilities
-from fastmcp.experimental.utilities.openapi import HTTPRoute
-from fastmcp.experimental.utilities.openapi.director import RequestDirector
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.dependencies import get_http_headers
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
+# Import from our new utilities
+from fastmcp.utilities.openapi import HTTPRoute
+from fastmcp.utilities.openapi.director import RequestDirector
+
if TYPE_CHECKING:
from fastmcp.server import Context
diff --git a/src/fastmcp/experimental/server/openapi/routing.py b/src/fastmcp/server/openapi/routing.py
similarity index 98%
rename from src/fastmcp/experimental/server/openapi/routing.py
rename to src/fastmcp/server/openapi/routing.py
index 1e3a54cea..ce937bb12 100644
--- a/src/fastmcp/experimental/server/openapi/routing.py
+++ b/src/fastmcp/server/openapi/routing.py
@@ -14,8 +14,8 @@ if TYPE_CHECKING:
OpenAPITool,
)
# Import from our new utilities
-from fastmcp.experimental.utilities.openapi import HttpMethod, HTTPRoute
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.openapi import HttpMethod, HTTPRoute
logger = get_logger(__name__)
diff --git a/src/fastmcp/experimental/server/openapi/server.py b/src/fastmcp/server/openapi/server.py
similarity index 98%
rename from src/fastmcp/experimental/server/openapi/server.py
rename to src/fastmcp/server/openapi/server.py
index 9df9b046f..8d26f9e88 100644
--- a/src/fastmcp/experimental/server/openapi/server.py
+++ b/src/fastmcp/server/openapi/server.py
@@ -7,16 +7,17 @@ from typing import Any, Literal
import httpx
from jsonschema_path import SchemaPath
+from fastmcp.server.server import FastMCP
+from fastmcp.utilities.logging import get_logger
+
# Import from our new utilities and components
-from fastmcp.experimental.utilities.openapi import (
+from fastmcp.utilities.openapi import (
HTTPRoute,
extract_output_schema_from_responses,
format_simple_description,
parse_openapi_to_http_routes,
)
-from fastmcp.experimental.utilities.openapi.director import RequestDirector
-from fastmcp.server.server import FastMCP
-from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.openapi.director import RequestDirector
from .components import (
OpenAPIResource,
@@ -247,7 +248,7 @@ class FastMCPOpenAPI(FastMCP):
# Create the new name
new_name = f"{name}_{self._used_names[component_type][name]}"
logger.debug(
- f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
+ f"Name collision detected: '{name}' already exists as a {component_type}. "
f"Using '{new_name}' instead."
)
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index fcacc3afd..d7cd94ceb 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -80,14 +80,6 @@ if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.client.client import FastMCP1Server
from fastmcp.client.transports import ClientTransport, ClientTransportT
- from fastmcp.experimental.server.openapi import FastMCPOpenAPI as FastMCPOpenAPINew
- from fastmcp.experimental.server.openapi.routing import (
- ComponentFn as OpenAPIComponentFnNew,
- )
- from fastmcp.experimental.server.openapi.routing import RouteMap as RouteMapNew
- from fastmcp.experimental.server.openapi.routing import (
- RouteMapFn as OpenAPIRouteMapFnNew,
- )
from fastmcp.server.openapi import ComponentFn as OpenAPIComponentFn
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
from fastmcp.server.openapi import RouteMapFn as OpenAPIRouteMapFn
@@ -2287,66 +2279,46 @@ class FastMCP(Generic[LifespanResultT]):
cls,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient,
- route_maps: list[RouteMap] | list[RouteMapNew] | None = None,
- route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None,
- mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None,
+ route_maps: list[RouteMap] | None = None,
+ route_map_fn: OpenAPIRouteMapFn | None = None,
+ mcp_component_fn: OpenAPIComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
tags: set[str] | None = None,
**settings: Any,
- ) -> FastMCPOpenAPI | FastMCPOpenAPINew:
+ ) -> FastMCPOpenAPI:
"""
Create a FastMCP server from an OpenAPI specification.
"""
+ from .openapi import FastMCPOpenAPI
- # Check if experimental parser is enabled
- if fastmcp.settings.experimental.enable_new_openapi_parser:
- from fastmcp.experimental.server.openapi import FastMCPOpenAPI
-
- return FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=client,
- route_maps=cast(Any, route_maps),
- route_map_fn=cast(Any, route_map_fn),
- mcp_component_fn=cast(Any, mcp_component_fn),
- mcp_names=mcp_names,
- tags=tags,
- **settings,
- )
- else:
- logger.info(
- "Using legacy OpenAPI parser. To use the new parser, set "
- "FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true. The new parser "
- "was introduced for testing in 2.11 and will become the default soon."
- )
- from .openapi import FastMCPOpenAPI
-
- return FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=client,
- route_maps=cast(Any, route_maps),
- route_map_fn=cast(Any, route_map_fn),
- mcp_component_fn=cast(Any, mcp_component_fn),
- mcp_names=mcp_names,
- tags=tags,
- **settings,
- )
+ return FastMCPOpenAPI(
+ openapi_spec=openapi_spec,
+ client=client,
+ route_maps=route_maps,
+ route_map_fn=route_map_fn,
+ mcp_component_fn=mcp_component_fn,
+ mcp_names=mcp_names,
+ tags=tags,
+ **settings,
+ )
@classmethod
def from_fastapi(
cls,
app: Any,
name: str | None = None,
- route_maps: list[RouteMap] | list[RouteMapNew] | None = None,
- route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None,
- mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None,
+ route_maps: list[RouteMap] | None = None,
+ route_map_fn: OpenAPIRouteMapFn | None = None,
+ mcp_component_fn: OpenAPIComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
httpx_client_kwargs: dict[str, Any] | None = None,
tags: set[str] | None = None,
**settings: Any,
- ) -> FastMCPOpenAPI | FastMCPOpenAPINew:
+ ) -> FastMCPOpenAPI:
"""
Create a FastMCP server from a FastAPI application.
"""
+ from .openapi import FastMCPOpenAPI
if httpx_client_kwargs is None:
httpx_client_kwargs = {}
@@ -2359,40 +2331,17 @@ class FastMCP(Generic[LifespanResultT]):
name = name or app.title
- # Check if experimental parser is enabled
- if fastmcp.settings.experimental.enable_new_openapi_parser:
- from fastmcp.experimental.server.openapi import FastMCPOpenAPI
-
- return FastMCPOpenAPI(
- openapi_spec=app.openapi(),
- client=client,
- name=name,
- route_maps=cast(Any, route_maps),
- route_map_fn=cast(Any, route_map_fn),
- mcp_component_fn=cast(Any, mcp_component_fn),
- mcp_names=mcp_names,
- tags=tags,
- **settings,
- )
- else:
- logger.info(
- "Using legacy OpenAPI parser. To use the new parser, set "
- "FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true. The new parser "
- "was introduced for testing in 2.11 and will become the default soon."
- )
- from .openapi import FastMCPOpenAPI
-
- return FastMCPOpenAPI(
- openapi_spec=app.openapi(),
- client=client,
- name=name,
- route_maps=cast(Any, route_maps),
- route_map_fn=cast(Any, route_map_fn),
- mcp_component_fn=cast(Any, mcp_component_fn),
- mcp_names=mcp_names,
- tags=tags,
- **settings,
- )
+ return FastMCPOpenAPI(
+ openapi_spec=app.openapi(),
+ client=client,
+ name=name,
+ route_maps=route_maps,
+ route_map_fn=route_map_fn,
+ mcp_component_fn=mcp_component_fn,
+ mcp_names=mcp_names,
+ tags=tags,
+ **settings,
+ )
@classmethod
def as_proxy(
diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py
index 3babc4044..69661c7fa 100644
--- a/src/fastmcp/settings.py
+++ b/src/fastmcp/settings.py
@@ -34,19 +34,24 @@ class ExperimentalSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="FASTMCP_EXPERIMENTAL_",
extra="ignore",
+ validate_assignment=True,
)
- enable_new_openapi_parser: Annotated[
- bool,
- Field(
- description=inspect.cleandoc(
- """
- Whether to use the new OpenAPI parser. This parser was introduced
- for testing in 2.11 and will become the default soon.
- """
- ),
- ),
- ] = False
+ # Deprecated in 2.14 - the new OpenAPI parser is now the default and only parser
+ enable_new_openapi_parser: bool = False
+
+ @field_validator("enable_new_openapi_parser", mode="after")
+ @classmethod
+ def _warn_openapi_parser_deprecated(cls, v: bool) -> bool:
+ if v:
+ warnings.warn(
+ "enable_new_openapi_parser is deprecated. "
+ "The new OpenAPI parser is now the default (and only) parser. "
+ "You can remove this setting.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return v
class Settings(BaseSettings):
diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py
deleted file mode 100644
index fa33953d7..000000000
--- a/src/fastmcp/utilities/openapi.py
+++ /dev/null
@@ -1,1568 +0,0 @@
-import json
-from typing import Any, Generic, Literal, TypeVar, cast
-
-from openapi_pydantic import (
- OpenAPI,
- Operation,
- Parameter,
- PathItem,
- Reference,
- RequestBody,
- Response,
- Schema,
-)
-
-# Import OpenAPI 3.0 models as well
-from openapi_pydantic.v3.v3_0 import OpenAPI as OpenAPI_30
-from openapi_pydantic.v3.v3_0 import Operation as Operation_30
-from openapi_pydantic.v3.v3_0 import Parameter as Parameter_30
-from openapi_pydantic.v3.v3_0 import PathItem as PathItem_30
-from openapi_pydantic.v3.v3_0 import Reference as Reference_30
-from openapi_pydantic.v3.v3_0 import RequestBody as RequestBody_30
-from openapi_pydantic.v3.v3_0 import Response as Response_30
-from openapi_pydantic.v3.v3_0 import Schema as Schema_30
-from pydantic import BaseModel, Field, ValidationError
-
-from fastmcp.utilities.logging import get_logger
-from fastmcp.utilities.types import FastMCPBaseModel
-
-logger = get_logger(__name__)
-
-# --- Intermediate Representation (IR) Definition ---
-# (IR models remain the same)
-
-HttpMethod = Literal[
- "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE"
-]
-ParameterLocation = Literal["path", "query", "header", "cookie"]
-JsonSchema = dict[str, Any]
-
-
-def format_array_parameter(
- values: list, parameter_name: str, is_query_parameter: bool = False
-) -> str | list:
- """
- Format an array parameter according to OpenAPI specifications.
-
- Args:
- values: List of values to format
- parameter_name: Name of the parameter (for error messages)
- is_query_parameter: If True, can return list for explode=True behavior
-
- Returns:
- String (comma-separated) or list (for query params with explode=True)
- """
- # For arrays of simple types (strings, numbers, etc.), join with commas
- if all(isinstance(item, str | int | float | bool) for item in values):
- return ",".join(str(v) for v in values)
-
- # For complex types, try to create a simpler representation
- try:
- # Try to create a simple string representation
- formatted_parts = []
- for item in values:
- if isinstance(item, dict):
- # For objects, serialize key-value pairs
- item_parts = []
- for k, v in item.items():
- item_parts.append(f"{k}:{v}")
- formatted_parts.append(".".join(item_parts))
- else:
- formatted_parts.append(str(item))
-
- return ",".join(formatted_parts)
- except Exception as e:
- param_type = "query" if is_query_parameter else "path"
- logger.warning(
- f"Failed to format complex array {param_type} parameter '{parameter_name}': {e}"
- )
-
- if is_query_parameter:
- # For query parameters, fallback to original list
- return values
- else:
- # For path parameters, fallback to string representation without Python syntax
- # Use str.translate() for efficient character removal
- translation_table = str.maketrans("", "", "[]'\"")
- str_value = str(values).translate(translation_table)
- return str_value
-
-
-def format_deep_object_parameter(
- param_value: dict, parameter_name: str
-) -> dict[str, str]:
- """
- Format a dictionary parameter for deepObject style serialization.
-
- According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
- object properties as separate query parameters with bracket notation.
-
- For example: `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`.
-
- Args:
- param_value: Dictionary value to format
- parameter_name: Name of the parameter
-
- Returns:
- Dictionary with bracketed parameter names as keys
- """
- if not isinstance(param_value, dict):
- logger.warning(
- f"deepObject style parameter '{parameter_name}' expected dict, got {type(param_value)}"
- )
- return {}
-
- result = {}
- for key, value in param_value.items():
- # Format as param[key]=value
- bracketed_key = f"{parameter_name}[{key}]"
- result[bracketed_key] = str(value)
-
- return result
-
-
-class ParameterInfo(FastMCPBaseModel):
- """Represents a single parameter for an HTTP operation in our IR."""
-
- name: str
- location: ParameterLocation # Mapped from 'in' field of openapi-pydantic Parameter
- required: bool = False
- schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
- description: str | None = None
- explode: bool | None = None # OpenAPI explode property for array parameters
- style: str | None = None # OpenAPI style property for parameter serialization
-
-
-class RequestBodyInfo(FastMCPBaseModel):
- """Represents the request body for an HTTP operation in our IR."""
-
- required: bool = False
- content_schema: dict[str, JsonSchema] = Field(
- default_factory=dict
- ) # Key: media type
- description: str | None = None
-
-
-class ResponseInfo(FastMCPBaseModel):
- """Represents response information in our IR."""
-
- description: str | None = None
- # Store schema per media type, key is media type
- content_schema: dict[str, JsonSchema] = Field(default_factory=dict)
-
-
-class HTTPRoute(FastMCPBaseModel):
- """Intermediate Representation for a single OpenAPI operation."""
-
- path: str
- method: HttpMethod
- operation_id: str | None = None
- summary: str | None = None
- description: str | None = None
- tags: list[str] = Field(default_factory=list)
- parameters: list[ParameterInfo] = Field(default_factory=list)
- request_body: RequestBodyInfo | None = None
- responses: dict[str, ResponseInfo] = Field(
- default_factory=dict
- ) # Key: status code str
- schema_definitions: dict[str, JsonSchema] = Field(
- default_factory=dict
- ) # Store component schemas
- extensions: dict[str, Any] = Field(default_factory=dict)
- openapi_version: str | None = None
-
-
-# Export public symbols
-__all__ = [
- "HTTPRoute",
- "HttpMethod",
- "JsonSchema",
- "ParameterInfo",
- "ParameterLocation",
- "RequestBodyInfo",
- "ResponseInfo",
- "_handle_nullable_fields",
- "extract_output_schema_from_responses",
- "format_deep_object_parameter",
- "parse_openapi_to_http_routes",
-]
-
-# Type variables for generic parser
-TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30)
-TSchema = TypeVar("TSchema", Schema, Schema_30)
-TReference = TypeVar("TReference", Reference, Reference_30)
-TParameter = TypeVar("TParameter", Parameter, Parameter_30)
-TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30)
-TResponse = TypeVar("TResponse", Response, Response_30)
-TOperation = TypeVar("TOperation", Operation, Operation_30)
-TPathItem = TypeVar("TPathItem", PathItem, PathItem_30)
-
-
-def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
- """
- Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
- using the openapi-pydantic library.
-
- Supports both OpenAPI 3.0.x and 3.1.x versions.
- """
- # Check OpenAPI version to use appropriate model
- openapi_version = openapi_dict.get("openapi", "")
-
- try:
- if openapi_version.startswith("3.0"):
- # Use OpenAPI 3.0 models
- openapi_30 = OpenAPI_30.model_validate(openapi_dict)
- logger.debug(
- f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
- )
- parser = OpenAPIParser(
- openapi_30,
- Reference_30,
- Schema_30,
- Parameter_30,
- RequestBody_30,
- Response_30,
- Operation_30,
- PathItem_30,
- openapi_version,
- )
- return parser.parse()
- else:
- # Default to OpenAPI 3.1 models
- openapi_31 = OpenAPI.model_validate(openapi_dict)
- logger.debug(
- f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
- )
- parser = OpenAPIParser(
- openapi_31,
- Reference,
- Schema,
- Parameter,
- RequestBody,
- Response,
- Operation,
- PathItem,
- openapi_version,
- )
- return parser.parse()
- except ValidationError as e:
- logger.error(f"OpenAPI schema validation failed: {e}")
- error_details = e.errors()
- logger.error(f"Validation errors: {error_details}")
- raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
-
-
-class OpenAPIParser(
- Generic[
- TOpenAPI,
- TReference,
- TSchema,
- TParameter,
- TRequestBody,
- TResponse,
- TOperation,
- TPathItem,
- ]
-):
- """Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1."""
-
- def __init__(
- self,
- openapi: TOpenAPI,
- reference_cls: type[TReference],
- schema_cls: type[TSchema],
- parameter_cls: type[TParameter],
- request_body_cls: type[TRequestBody],
- response_cls: type[TResponse],
- operation_cls: type[TOperation],
- path_item_cls: type[TPathItem],
- openapi_version: str,
- ):
- """Initialize the parser with the OpenAPI schema and type classes."""
- self.openapi = openapi
- self.reference_cls = reference_cls
- self.schema_cls = schema_cls
- self.parameter_cls = parameter_cls
- self.request_body_cls = request_body_cls
- self.response_cls = response_cls
- self.operation_cls = operation_cls
- self.path_item_cls = path_item_cls
- self.openapi_version = openapi_version
-
- def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
- """Convert string parameter location to our ParameterLocation type."""
- if param_in in ["path", "query", "header", "cookie"]:
- return param_in # type: ignore[return-value] # Safe cast since we checked values
- logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
- return "query" # type: ignore[return-value] # Safe cast to default value
-
- def _resolve_ref(self, item: Any) -> Any:
- """Resolves a reference to its target definition."""
- if isinstance(item, self.reference_cls):
- ref_str = item.ref
- try:
- if not ref_str.startswith("#/"):
- raise ValueError(
- f"External or non-local reference not supported: {ref_str}"
- )
-
- parts = ref_str.strip("#/").split("/")
- target = self.openapi
-
- for part in parts:
- if part.isdigit() and isinstance(target, list):
- target = target[int(part)]
- elif isinstance(target, BaseModel):
- # Check class fields first, then model_extra
- if part in target.__class__.model_fields:
- target = getattr(target, part, None)
- elif target.model_extra and part in target.model_extra:
- target = target.model_extra[part]
- else:
- # Special handling for components
- if part == "components" and hasattr(target, "components"):
- target = target.components
- elif hasattr(target, part): # Fallback check
- target = getattr(target, part, None)
- else:
- target = None # Part not found
- elif isinstance(target, dict):
- target = target.get(part)
- else:
- raise ValueError(
- f"Cannot traverse part '{part}' in reference '{ref_str}'"
- )
-
- if target is None:
- raise ValueError(
- f"Reference part '{part}' not found in path '{ref_str}'"
- )
-
- # Handle nested references
- if isinstance(target, self.reference_cls):
- return self._resolve_ref(target)
-
- return target
- except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
- raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
-
- return item
-
- def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema:
- """Resolves a schema and returns it as a dictionary."""
- try:
- resolved_schema = self._resolve_ref(schema_obj)
-
- if isinstance(resolved_schema, (self.schema_cls)):
- # Convert schema to dictionary
- result = resolved_schema.model_dump(
- mode="json", by_alias=True, exclude_none=True
- )
- elif isinstance(resolved_schema, dict):
- result = resolved_schema
- else:
- logger.warning(
- f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
- )
- result = {}
-
- return _replace_ref_with_defs(result)
- except ValueError as e:
- # Re-raise ValueError for external reference errors and other validation issues
- if "External or non-local reference not supported" in str(e):
- raise
- logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
- return {}
- except Exception as e:
- logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
- return {}
-
- def _extract_parameters(
- self,
- operation_params: list[Any] | None = None,
- path_item_params: list[Any] | None = None,
- ) -> list[ParameterInfo]:
- """Extract and resolve parameters from operation and path item."""
- extracted_params: list[ParameterInfo] = []
- seen_params: dict[
- tuple[str, str], bool
- ] = {} # Use tuple of (name, location) as key
- all_params = (operation_params or []) + (path_item_params or [])
-
- for param_or_ref in all_params:
- try:
- parameter = self._resolve_ref(param_or_ref)
-
- if not isinstance(parameter, self.parameter_cls):
- logger.warning(
- f"Expected Parameter after resolving, got {type(parameter)}. Skipping."
- )
- continue
-
- # Extract parameter info - handle both 3.0 and 3.1 parameter models
- param_in = parameter.param_in # Both use param_in
- # Handle enum or string parameter locations
- from enum import Enum
-
- param_in_str = (
- param_in.value if isinstance(param_in, Enum) else param_in
- )
- param_location = self._convert_to_parameter_location(param_in_str)
- param_schema_obj = parameter.param_schema # Both use param_schema
-
- # Skip duplicate parameters (same name and location)
- param_key = (parameter.name, param_in_str)
- if param_key in seen_params:
- continue
- seen_params[param_key] = True
-
- # Extract schema
- param_schema_dict = {}
- if param_schema_obj:
- # Process schema object
- param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
-
- # Handle default value
- resolved_schema = self._resolve_ref(param_schema_obj)
- if (
- not isinstance(resolved_schema, self.reference_cls)
- and hasattr(resolved_schema, "default")
- and resolved_schema.default is not None
- ):
- param_schema_dict["default"] = resolved_schema.default
-
- elif hasattr(parameter, "content") and parameter.content:
- # Handle content-based parameters
- first_media_type = next(iter(parameter.content.values()), None)
- if (
- first_media_type
- and hasattr(first_media_type, "media_type_schema")
- and first_media_type.media_type_schema
- ):
- media_schema = first_media_type.media_type_schema
- param_schema_dict = self._extract_schema_as_dict(media_schema)
-
- # Handle default value in content schema
- resolved_media_schema = self._resolve_ref(media_schema)
- if (
- not isinstance(resolved_media_schema, self.reference_cls)
- and hasattr(resolved_media_schema, "default")
- and resolved_media_schema.default is not None
- ):
- param_schema_dict["default"] = resolved_media_schema.default
-
- # Extract explode and style properties if present
- explode = getattr(parameter, "explode", None)
- style = getattr(parameter, "style", None)
-
- # Create parameter info object
- param_info = ParameterInfo(
- name=parameter.name,
- location=param_location,
- required=parameter.required,
- schema=param_schema_dict,
- description=parameter.description,
- explode=explode,
- style=style,
- )
- extracted_params.append(param_info)
- except Exception as e:
- param_name = getattr(
- param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
- )
- logger.error(
- f"Failed to extract parameter '{param_name}': {e}", exc_info=False
- )
-
- return extracted_params
-
- def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None:
- """Extract and resolve request body information."""
- if not request_body_or_ref:
- return None
-
- try:
- request_body = self._resolve_ref(request_body_or_ref)
-
- if not isinstance(request_body, self.request_body_cls):
- logger.warning(
- f"Expected RequestBody after resolving, got {type(request_body)}. Returning None."
- )
- return None
-
- # Create request body info
- request_body_info = RequestBodyInfo(
- required=request_body.required,
- description=request_body.description,
- )
-
- # Extract content schemas
- if hasattr(request_body, "content") and request_body.content:
- for media_type_str, media_type_obj in request_body.content.items():
- if (
- media_type_obj
- and hasattr(media_type_obj, "media_type_schema")
- and media_type_obj.media_type_schema
- ):
- try:
- schema_dict = self._extract_schema_as_dict(
- media_type_obj.media_type_schema
- )
- request_body_info.content_schema[media_type_str] = (
- schema_dict
- )
- except ValueError as e:
- # Re-raise ValueError for external reference errors
- if "External or non-local reference not supported" in str(
- e
- ):
- raise
- logger.error(
- f"Failed to extract schema for media type '{media_type_str}': {e}"
- )
- except Exception as e:
- logger.error(
- f"Failed to extract schema for media type '{media_type_str}': {e}"
- )
-
- return request_body_info
- except ValueError as e:
- # Re-raise ValueError for external reference errors
- if "External or non-local reference not supported" in str(e):
- raise
- ref_name = getattr(request_body_or_ref, "ref", "unknown")
- logger.error(
- f"Failed to extract request body '{ref_name}': {e}", exc_info=False
- )
- return None
- except Exception as e:
- ref_name = getattr(request_body_or_ref, "ref", "unknown")
- logger.error(
- f"Failed to extract request body '{ref_name}': {e}", exc_info=False
- )
- return None
-
- def _extract_responses(
- self, operation_responses: dict[str, Any] | None
- ) -> dict[str, ResponseInfo]:
- """Extract and resolve response information."""
- extracted_responses: dict[str, ResponseInfo] = {}
-
- if not operation_responses:
- return extracted_responses
-
- for status_code, resp_or_ref in operation_responses.items():
- try:
- response = self._resolve_ref(resp_or_ref)
-
- if not isinstance(response, self.response_cls):
- logger.warning(
- f"Expected Response after resolving for status code {status_code}, "
- f"got {type(response)}. Skipping."
- )
- continue
-
- # Create response info
- resp_info = ResponseInfo(description=response.description)
-
- # Extract content schemas
- if hasattr(response, "content") and response.content:
- for media_type_str, media_type_obj in response.content.items():
- if (
- media_type_obj
- and hasattr(media_type_obj, "media_type_schema")
- and media_type_obj.media_type_schema
- ):
- try:
- schema_dict = self._extract_schema_as_dict(
- media_type_obj.media_type_schema
- )
- resp_info.content_schema[media_type_str] = schema_dict
- except ValueError as e:
- # Re-raise ValueError for external reference errors
- if (
- "External or non-local reference not supported"
- in str(e)
- ):
- raise
- logger.error(
- f"Failed to extract schema for media type '{media_type_str}' "
- f"in response {status_code}: {e}"
- )
- except Exception as e:
- logger.error(
- f"Failed to extract schema for media type '{media_type_str}' "
- f"in response {status_code}: {e}"
- )
-
- extracted_responses[str(status_code)] = resp_info
- except ValueError as e:
- # Re-raise ValueError for external reference errors
- if "External or non-local reference not supported" in str(e):
- raise
- ref_name = getattr(resp_or_ref, "ref", "unknown")
- logger.error(
- f"Failed to extract response for status code {status_code} "
- f"from reference '{ref_name}': {e}",
- exc_info=False,
- )
- except Exception as e:
- ref_name = getattr(resp_or_ref, "ref", "unknown")
- logger.error(
- f"Failed to extract response for status code {status_code} "
- f"from reference '{ref_name}': {e}",
- exc_info=False,
- )
-
- return extracted_responses
-
- def parse(self) -> list[HTTPRoute]:
- """Parse the OpenAPI schema into HTTP routes."""
- routes: list[HTTPRoute] = []
-
- if not hasattr(self.openapi, "paths") or not self.openapi.paths:
- logger.warning("OpenAPI schema has no paths defined.")
- return []
-
- # Extract component schemas
- schema_definitions = {}
- if hasattr(self.openapi, "components") and self.openapi.components:
- components = self.openapi.components
- if hasattr(components, "schemas") and components.schemas:
- for name, schema in components.schemas.items():
- try:
- if isinstance(schema, self.reference_cls):
- resolved_schema = self._resolve_ref(schema)
- schema_definitions[name] = self._extract_schema_as_dict(
- resolved_schema
- )
- else:
- schema_definitions[name] = self._extract_schema_as_dict(
- schema
- )
- except Exception as e:
- logger.warning(
- f"Failed to extract schema definition '{name}': {e}"
- )
-
- # Process paths and operations
- for path_str, path_item_obj in self.openapi.paths.items():
- if not isinstance(path_item_obj, self.path_item_cls):
- logger.warning(
- f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})"
- )
- continue
-
- path_level_params = (
- path_item_obj.parameters
- if hasattr(path_item_obj, "parameters")
- else None
- )
-
- # Get HTTP methods from the path item class fields
- http_methods = [
- "get",
- "put",
- "post",
- "delete",
- "options",
- "head",
- "patch",
- "trace",
- ]
- for method_lower in http_methods:
- operation = getattr(path_item_obj, method_lower, None)
-
- if operation and isinstance(operation, self.operation_cls):
- # Cast method to HttpMethod - safe since we only use valid HTTP methods
- method_upper = method_lower.upper()
-
- try:
- parameters = self._extract_parameters(
- getattr(operation, "parameters", None), path_level_params
- )
-
- request_body_info = self._extract_request_body(
- getattr(operation, "requestBody", None)
- )
-
- responses = self._extract_responses(
- getattr(operation, "responses", None)
- )
-
- extensions = {}
- if hasattr(operation, "model_extra") and operation.model_extra:
- extensions = {
- k: v
- for k, v in operation.model_extra.items()
- if k.startswith("x-")
- }
-
- route = HTTPRoute(
- path=path_str,
- method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
- operation_id=getattr(operation, "operationId", None),
- summary=getattr(operation, "summary", None),
- description=getattr(operation, "description", None),
- tags=getattr(operation, "tags", []) or [],
- parameters=parameters,
- request_body=request_body_info,
- responses=responses,
- schema_definitions=schema_definitions,
- extensions=extensions,
- openapi_version=self.openapi_version,
- )
- routes.append(route)
- logger.debug(
- f"Successfully extracted route: {method_upper} {path_str}"
- )
- except ValueError as op_error:
- # Re-raise ValueError for external reference errors
- if "External or non-local reference not supported" in str(
- op_error
- ):
- raise
- op_id = getattr(operation, "operationId", "unknown")
- logger.error(
- f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
- exc_info=True,
- )
- except Exception as op_error:
- op_id = getattr(operation, "operationId", "unknown")
- logger.error(
- f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
- exc_info=True,
- )
-
- logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
- return routes
-
-
-def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
- """
- Clean up a schema dictionary for display by removing internal/complex fields.
- """
- if not schema or not isinstance(schema, dict):
- return schema
-
- # Make a copy to avoid modifying the input schema
- cleaned = schema.copy()
-
- # Fields commonly removed for simpler display to LLMs or users
- fields_to_remove = [
- "allOf",
- "anyOf",
- "oneOf",
- "not", # Composition keywords
- "nullable", # Handled by type unions usually
- "discriminator",
- "readOnly",
- "writeOnly",
- "deprecated",
- "xml",
- "externalDocs",
- # Can be verbose, maybe remove based on flag?
- # "pattern", "minLength", "maxLength",
- # "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
- # "multipleOf", "minItems", "maxItems", "uniqueItems",
- # "minProperties", "maxProperties"
- ]
-
- for field in fields_to_remove:
- if field in cleaned:
- cleaned.pop(field)
-
- # Recursively clean properties and items
- if "properties" in cleaned:
- cleaned["properties"] = {
- k: clean_schema_for_display(v) for k, v in cleaned["properties"].items()
- }
- # Remove properties section if empty after cleaning
- if not cleaned["properties"]:
- cleaned.pop("properties")
-
- if "items" in cleaned:
- cleaned["items"] = clean_schema_for_display(cleaned["items"])
- # Remove items section if empty after cleaning
- if not cleaned["items"]:
- cleaned.pop("items")
-
- if "additionalProperties" in cleaned:
- # Often verbose, can be simplified
- if isinstance(cleaned["additionalProperties"], dict):
- cleaned["additionalProperties"] = clean_schema_for_display(
- cleaned["additionalProperties"]
- )
- elif cleaned["additionalProperties"] is True:
- # Maybe keep 'true' or represent as 'Allows additional properties' text?
- pass # Keep simple boolean for now
-
-
-def generate_example_from_schema(schema: JsonSchema | None) -> Any:
- """
- Generate a simple example value from a JSON schema dictionary.
- Very basic implementation focusing on types.
- """
- if not schema or not isinstance(schema, dict):
- return "unknown" # Or None?
-
- # Use default value if provided
- if "default" in schema:
- return schema["default"]
- # Use first enum value if provided
- if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]:
- return schema["enum"][0]
- # Use first example if provided
- if (
- "examples" in schema
- and isinstance(schema["examples"], list)
- and schema["examples"]
- ):
- return schema["examples"][0]
- if "example" in schema:
- return schema["example"]
-
- schema_type = schema.get("type")
-
- if schema_type == "object":
- result = {}
- properties = schema.get("properties", {})
- if isinstance(properties, dict):
- # Generate example for first few properties or required ones? Limit complexity.
- required_props = set(schema.get("required", []))
- props_to_include = list(properties.keys())[
- :3
- ] # Limit to first 3 for brevity
- for prop_name in props_to_include:
- if prop_name in properties:
- result[prop_name] = generate_example_from_schema(
- properties[prop_name]
- )
- # Ensure required props are present if possible
- for req_prop in required_props:
- if req_prop not in result and req_prop in properties:
- result[req_prop] = generate_example_from_schema(
- properties[req_prop]
- )
- return result if result else {"key": "value"} # Basic object if no props
-
- elif schema_type == "array":
- items_schema = schema.get("items")
- if isinstance(items_schema, dict):
- # Generate one example item
- item_example = generate_example_from_schema(items_schema)
- return [item_example] if item_example is not None else []
- return ["example_item"] # Fallback
-
- elif schema_type == "string":
- format_type = schema.get("format")
- if format_type == "date-time":
- return "2024-01-01T12:00:00Z"
- if format_type == "date":
- return "2024-01-01"
- if format_type == "email":
- return "user@example.com"
- if format_type == "uuid":
- return "123e4567-e89b-12d3-a456-426614174000"
- if format_type == "byte":
- return "ZXhhbXBsZQ==" # "example" base64
- return "string"
-
- elif schema_type == "integer":
- return 1
- elif schema_type == "number":
- return 1.5
- elif schema_type == "boolean":
- return True
- elif schema_type == "null":
- return None
-
- # Fallback if type is unknown or missing
- return "unknown_type"
-
-
-def format_json_for_description(data: Any, indent: int = 2) -> str:
- """Formats Python data as a JSON string block for markdown."""
- try:
- json_str = json.dumps(data, indent=indent)
- return f"```json\n{json_str}\n```"
- except TypeError:
- return f"```\nCould not serialize to JSON: {data}\n```"
-
-
-def format_description_with_responses(
- base_description: str,
- responses: dict[
- str, Any
- ], # Changed from specific ResponseInfo type to avoid circular imports
- parameters: list[ParameterInfo] | None = None, # Add parameters parameter
- request_body: RequestBodyInfo | None = None, # Add request_body parameter
-) -> str:
- """
- Formats the base description string with response, parameter, and request body information.
-
- Args:
- base_description (str): The initial description to be formatted.
- responses (dict[str, Any]): A dictionary of response information, keyed by status code.
- parameters (list[ParameterInfo] | None, optional): A list of parameter information,
- including path and query parameters. Each parameter includes details such as name,
- location, whether it is required, and a description.
- request_body (RequestBodyInfo | None, optional): Information about the request body,
- including its description, whether it is required, and its content schema.
-
- Returns:
- str: The formatted description string with additional details about responses, parameters,
- and the request body.
- """
- desc_parts = [base_description]
-
- # Add parameter information
- if parameters:
- # Process path parameters
- path_params = [p for p in parameters if p.location == "path"]
- if path_params:
- param_section = "\n\n**Path Parameters:**"
- desc_parts.append(param_section)
- for param in path_params:
- required_marker = " (Required)" if param.required else ""
- param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}"
- desc_parts.append(param_desc)
-
- # Process query parameters
- query_params = [p for p in parameters if p.location == "query"]
- if query_params:
- param_section = "\n\n**Query Parameters:**"
- desc_parts.append(param_section)
- for param in query_params:
- required_marker = " (Required)" if param.required else ""
- param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}"
- desc_parts.append(param_desc)
-
- # Add request body information if present
- if request_body and request_body.description:
- req_body_section = "\n\n**Request Body:**"
- desc_parts.append(req_body_section)
- required_marker = " (Required)" if request_body.required else ""
- desc_parts.append(f"\n{request_body.description}{required_marker}")
-
- # Add request body property descriptions if available
- if request_body.content_schema:
- media_type = (
- "application/json"
- if "application/json" in request_body.content_schema
- else next(iter(request_body.content_schema), None)
- )
- if media_type:
- schema = request_body.content_schema.get(media_type, {})
- if isinstance(schema, dict) and "properties" in schema:
- desc_parts.append("\n\n**Request Properties:**")
- for prop_name, prop_schema in schema["properties"].items():
- if (
- isinstance(prop_schema, dict)
- and "description" in prop_schema
- ):
- required = prop_name in schema.get("required", [])
- req_mark = " (Required)" if required else ""
- desc_parts.append(
- f"\n- **{prop_name}**{req_mark}: {prop_schema['description']}"
- )
-
- # Add response information
- if responses:
- response_section = "\n\n**Responses:**"
- added_response_section = False
-
- # Determine success codes (common ones)
- success_codes = {"200", "201", "202", "204"} # As strings
- success_status = next((s for s in success_codes if s in responses), None)
-
- # Process all responses
- responses_to_process = responses.items()
-
- for status_code, resp_info in sorted(responses_to_process):
- if not added_response_section:
- desc_parts.append(response_section)
- added_response_section = True
-
- status_marker = " (Success)" if status_code == success_status else ""
- desc_parts.append(
- f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}"
- )
-
- # Process content schemas for this response
- if resp_info.content_schema:
- # Prioritize json, then take first available
- media_type = (
- "application/json"
- if "application/json" in resp_info.content_schema
- else next(iter(resp_info.content_schema), None)
- )
-
- if media_type:
- schema = resp_info.content_schema.get(media_type)
- desc_parts.append(f" - Content-Type: `{media_type}`")
-
- # Add response property descriptions
- if isinstance(schema, dict):
- # Handle array responses
- if schema.get("type") == "array" and "items" in schema:
- items_schema = schema["items"]
- if (
- isinstance(items_schema, dict)
- and "properties" in items_schema
- ):
- desc_parts.append("\n - **Response Item Properties:**")
- for prop_name, prop_schema in items_schema[
- "properties"
- ].items():
- if (
- isinstance(prop_schema, dict)
- and "description" in prop_schema
- ):
- desc_parts.append(
- f"\n - **{prop_name}**: {prop_schema['description']}"
- )
- # Handle object responses
- elif "properties" in schema:
- desc_parts.append("\n - **Response Properties:**")
- for prop_name, prop_schema in schema["properties"].items():
- if (
- isinstance(prop_schema, dict)
- and "description" in prop_schema
- ):
- desc_parts.append(
- f"\n - **{prop_name}**: {prop_schema['description']}"
- )
-
- # Generate Example
- if schema:
- example = generate_example_from_schema(schema)
- if example != "unknown_type" and example is not None:
- desc_parts.append("\n - **Example:**")
- desc_parts.append(
- format_json_for_description(example, indent=2)
- )
-
- return "\n".join(desc_parts)
-
-
-def _replace_ref_with_defs(
- info: dict[str, Any], description: str | None = None
-) -> dict[str, Any]:
- """
- Replace openapi $ref with jsonschema $defs
-
- Examples:
- - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
- - {"$ref": "#/components/schemas/..."}
- - {"items": {"$ref": "#/components/schemas/..."}}
- - {"anyOf": [{"$ref": "#/components/schemas/..."}]}
- - {"allOf": [{"$ref": "#/components/schemas/..."}]}
- - {"oneOf": [{"$ref": "#/components/schemas/..."}]}
-
- Args:
- info: dict[str, Any]
- description: str | None
-
- Returns:
- dict[str, Any]
- """
- schema = info.copy()
- if ref_path := schema.get("$ref"):
- if isinstance(ref_path, str):
- if ref_path.startswith("#/components/schemas/"):
- schema_name = ref_path.split("/")[-1]
- schema["$ref"] = f"#/$defs/{schema_name}"
- elif not ref_path.startswith("#/"):
- raise ValueError(
- f"External or non-local reference not supported: {ref_path}. "
- f"FastMCP only supports local schema references starting with '#/'. "
- f"Please include all schema definitions within the OpenAPI document."
- )
- elif properties := schema.get("properties"):
- if "$ref" in properties:
- schema["properties"] = _replace_ref_with_defs(properties)
- else:
- schema["properties"] = {
- prop_name: _replace_ref_with_defs(prop_schema)
- for prop_name, prop_schema in properties.items()
- }
- elif item_schema := schema.get("items"):
- schema["items"] = _replace_ref_with_defs(item_schema)
- for section in ["anyOf", "allOf", "oneOf"]:
- for i, item in enumerate(schema.get(section, [])):
- schema[section][i] = _replace_ref_with_defs(item)
- if info.get("description", description) and not schema.get("description"):
- schema["description"] = description
- return schema
-
-
-def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
- """
- Make an optional parameter schema nullable to allow None values.
-
- For optional parameters, we need to allow null values in addition to the
- specified type to handle cases where None is passed for optional parameters.
- """
- # If schema already has multiple types or is already nullable, don't modify
- if "anyOf" in schema or "oneOf" in schema or "allOf" in schema:
- return schema
-
- # If it's already nullable (type includes null), don't modify
- if isinstance(schema.get("type"), list) and "null" in schema["type"]:
- return schema
-
- # Create a new schema that allows null in addition to the original type
- if "type" in schema:
- original_type = schema["type"]
-
- if isinstance(original_type, str):
- # Single type - make it a union with null
- # Optimize: avoid full schema copy by building directly
- nested_non_nullable_schema = {
- "type": original_type,
- }
- nullable_schema = {}
-
- # Define type-specific properties that should move to nested schema
- type_specific_properties = set()
- if original_type == "array":
- # https://json-schema.org/understanding-json-schema/reference/array
- type_specific_properties = {
- "items",
- "prefixItems",
- "unevaluatedItems",
- "contains",
- "minContains",
- "maxContains",
- "minItems",
- "maxItems",
- "uniqueItems",
- }
- elif original_type == "object":
- # https://json-schema.org/understanding-json-schema/reference/object
- type_specific_properties = {
- "properties",
- "patternProperties",
- "additionalProperties",
- "unevaluatedProperties",
- "required",
- "propertyNames",
- "minProperties",
- "maxProperties",
- }
-
- # Efficiently distribute properties without copying the entire schema
- for key, value in schema.items():
- if key == "type":
- continue # Already handled
- elif key in type_specific_properties:
- nested_non_nullable_schema[key] = value
- else:
- nullable_schema[key] = value
-
- nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
- return nullable_schema
-
- return schema
-
-
-def _add_null_to_type(schema: dict[str, Any]) -> None:
- """Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
- if "type" in schema:
- current_type = schema["type"]
-
- if isinstance(current_type, str):
- # Convert string type to array with null
- schema["type"] = [current_type, "null"]
- elif isinstance(current_type, list):
- # Add null to array if not already present
- if "null" not in current_type:
- schema["type"] = [*current_type, "null"]
- elif "oneOf" in schema:
- # Convert oneOf to anyOf with null type
- schema["anyOf"] = [*schema.pop("oneOf"), {"type": "null"}]
- elif "anyOf" in schema:
- # Add null type to anyOf if not already present
- if not any(item.get("type") == "null" for item in schema["anyOf"]):
- schema["anyOf"].append({"type": "null"})
- elif "allOf" in schema:
- # For allOf, wrap in anyOf with null - this means (all conditions) OR null
- schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
-
-
-def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
- """Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
- "nullable": true} -> {"type": ["string", "null"]}"""
-
- if not isinstance(schema, dict):
- return schema
-
- # Check if we need to modify anything first to avoid unnecessary copying
- has_root_nullable_field = "nullable" in schema
- has_root_nullable_true = (
- has_root_nullable_field
- and schema["nullable"]
- and (
- "type" in schema
- or "oneOf" in schema
- or "anyOf" in schema
- or "allOf" in schema
- )
- )
-
- has_property_nullable_field = False
- if "properties" in schema:
- for prop_schema in schema["properties"].values():
- if isinstance(prop_schema, dict) and "nullable" in prop_schema:
- has_property_nullable_field = True
- break
-
- # If no nullable fields at all, return original schema unchanged
- if not has_root_nullable_field and not has_property_nullable_field:
- return schema
-
- # Only copy if we need to modify
- result = schema.copy()
-
- # Handle root level nullable - always remove the field, convert type if true
- if has_root_nullable_field:
- result.pop("nullable")
- if has_root_nullable_true:
- _add_null_to_type(result)
-
- # Handle properties nullable fields
- if has_property_nullable_field and "properties" in result:
- for _prop_name, prop_schema in result["properties"].items():
- if isinstance(prop_schema, dict) and "nullable" in prop_schema:
- nullable_value = prop_schema.pop("nullable")
- if nullable_value and (
- "type" in prop_schema
- or "oneOf" in prop_schema
- or "anyOf" in prop_schema
- or "allOf" in prop_schema
- ):
- _add_null_to_type(prop_schema)
-
- return result
-
-
-def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
- """
- Combines parameter and request body schemas into a single schema.
- Handles parameter name collisions by adding location suffixes.
-
- Args:
- route: HTTPRoute object
-
- Returns:
- Combined schema dictionary
- """
- properties = {}
- required = []
-
- # First pass: collect parameter names by location and body properties
- param_names_by_location = {
- "path": set(),
- "query": set(),
- "header": set(),
- "cookie": set(),
- }
- body_props = {}
-
- for param in route.parameters:
- param_names_by_location[param.location].add(param.name)
-
- if route.request_body and route.request_body.content_schema:
- content_type = next(iter(route.request_body.content_schema))
- body_schema = _replace_ref_with_defs(
- route.request_body.content_schema[content_type].copy(),
- route.request_body.description,
- )
- body_props = body_schema.get("properties", {})
-
- # Detect collisions: parameters that exist in both body and path/query/header
- all_non_body_params = set()
- for location_params in param_names_by_location.values():
- all_non_body_params.update(location_params)
-
- body_param_names = set(body_props.keys())
- colliding_params = all_non_body_params & body_param_names
-
- # Add parameters with suffixes for collisions
- for param in route.parameters:
- if param.name in colliding_params:
- # Add suffix for non-body parameters when collision detected
- suffixed_name = f"{param.name}__{param.location}"
- if param.required:
- required.append(suffixed_name)
-
- # Add location info to description
- param_schema = _replace_ref_with_defs(
- param.schema_.copy(), param.description
- )
- original_desc = param_schema.get("description", "")
- location_desc = f"({param.location.capitalize()} parameter)"
- if original_desc:
- param_schema["description"] = f"{original_desc} {location_desc}"
- else:
- param_schema["description"] = location_desc
-
- # Don't make optional parameters nullable - they can simply be omitted
- # The OpenAPI specification doesn't require optional parameters to accept null values
-
- properties[suffixed_name] = param_schema
- else:
- # No collision, use original name
- if param.required:
- required.append(param.name)
- param_schema = _replace_ref_with_defs(
- param.schema_.copy(), param.description
- )
-
- # Don't make optional parameters nullable - they can simply be omitted
- # The OpenAPI specification doesn't require optional parameters to accept null values
-
- properties[param.name] = param_schema
-
- # Add request body properties (no suffixes for body parameters)
- if route.request_body and route.request_body.content_schema:
- for prop_name, prop_schema in body_props.items():
- properties[prop_name] = prop_schema
-
- if route.request_body.required:
- required.extend(body_schema.get("required", []))
-
- result = {
- "type": "object",
- "properties": properties,
- "required": required,
- }
- # Add schema definitions if available
- if route.schema_definitions:
- result["$defs"] = route.schema_definitions.copy()
-
- # Use lightweight compression - prune additionalProperties and unused definitions
- if result.get("additionalProperties") is False:
- result.pop("additionalProperties")
-
- # Remove unused definitions (lightweight approach - just check direct $ref usage)
- if "$defs" in result:
- used_refs = set()
-
- def find_refs_in_value(value):
- if isinstance(value, dict):
- if "$ref" in value and isinstance(value["$ref"], str):
- ref = value["$ref"]
- if ref.startswith("#/$defs/"):
- used_refs.add(ref.split("/")[-1])
- for v in value.values():
- find_refs_in_value(v)
- elif isinstance(value, list):
- for item in value:
- find_refs_in_value(item)
-
- # Find refs in the main schema (excluding $defs section)
- for key, value in result.items():
- if key != "$defs":
- find_refs_in_value(value)
-
- # Remove unused definitions
- if used_refs:
- result["$defs"] = {
- name: def_schema
- for name, def_schema in result["$defs"].items() # type: ignore[index]
- if name in used_refs
- }
- else:
- result.pop("$defs")
-
- return result
-
-
-def _adjust_union_types(
- schema: dict[str, Any] | list[Any],
-) -> dict[str, Any] | list[Any]:
- """Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
- if isinstance(schema, dict):
- # Optimize: only copy if we need to modify something
- has_one_of = "oneOf" in schema
- needs_recursive_processing = False
-
- # Check if we need recursive processing
- for v in schema.values():
- if isinstance(v, dict | list):
- needs_recursive_processing = True
- break
-
- # If nothing to change, return original
- if not has_one_of and not needs_recursive_processing:
- return schema
-
- # Work on a copy only when modification is needed
- result = schema.copy()
- if has_one_of:
- result["anyOf"] = result.pop("oneOf")
-
- # Only recurse where needed
- if needs_recursive_processing:
- for k, v in result.items():
- if isinstance(v, dict | list):
- result[k] = _adjust_union_types(v)
-
- return result
- elif isinstance(schema, list):
- return [_adjust_union_types(item) for item in schema]
- return schema
-
-
-def extract_output_schema_from_responses(
- responses: dict[str, ResponseInfo],
- schema_definitions: dict[str, Any] | None = None,
- openapi_version: str | None = None,
-) -> dict[str, Any] | None:
- """
- Extract output schema from OpenAPI responses for use as MCP tool output schema.
-
- This function finds the first successful response (200, 201, 202, 204) with a
- JSON-compatible content type and extracts its schema. If the schema is not an
- object type, it wraps it to comply with MCP requirements.
-
- Args:
- responses: Dictionary of ResponseInfo objects keyed by status code
- schema_definitions: Optional schema definitions to include in the output schema
- openapi_version: OpenAPI version string, used to optimize nullable field handling
-
- Returns:
- dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
- """
- if not responses:
- return None
-
- # Priority order for success status codes
- success_codes = ["200", "201", "202", "204"]
-
- # Find the first successful response
- response_info = None
- for status_code in success_codes:
- if status_code in responses:
- response_info = responses[status_code]
- break
-
- # If no explicit success codes, try any 2xx response
- if response_info is None:
- for status_code, resp_info in responses.items():
- if status_code.startswith("2"):
- response_info = resp_info
- break
-
- if response_info is None or not response_info.content_schema:
- return None
-
- # Prefer application/json, then fall back to other JSON-compatible types
- json_compatible_types = [
- "application/json",
- "application/vnd.api+json",
- "application/hal+json",
- "application/ld+json",
- "text/json",
- ]
-
- schema = None
- for content_type in json_compatible_types:
- if content_type in response_info.content_schema:
- schema = response_info.content_schema[content_type]
- break
-
- # If no JSON-compatible type found, try the first available content type
- if schema is None and response_info.content_schema:
- first_content_type = next(iter(response_info.content_schema))
- schema = response_info.content_schema[first_content_type]
- logger.debug(
- f"Using non-JSON content type for output schema: {first_content_type}"
- )
-
- if not schema or not isinstance(schema, dict):
- return None
-
- # Clean and copy the schema
- output_schema = schema.copy()
-
- # If schema has a $ref, resolve it first before processing nullable fields
- if "$ref" in output_schema and schema_definitions:
- ref_path = output_schema["$ref"]
- if ref_path.startswith("#/components/schemas/"):
- schema_name = ref_path.split("/")[-1]
- if schema_name in schema_definitions:
- # Replace $ref with the actual schema definition
- output_schema = schema_definitions[schema_name].copy()
-
- # Handle OpenAPI nullable fields by converting them to JSON Schema format
- # This prevents "None is not of type 'string'" validation errors
- # Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
- if openapi_version and openapi_version.startswith("3.0"):
- output_schema = _handle_nullable_fields(output_schema)
-
- # MCP requires output schemas to be objects. If this schema is not an object,
- # we need to wrap it similar to how ParsedFunction.from_function() does it
- if output_schema.get("type") != "object":
- # Create a wrapped schema that contains the original schema under a "result" key
- wrapped_schema = {
- "type": "object",
- "properties": {"result": output_schema},
- "required": ["result"],
- "x-fastmcp-wrap-result": True,
- }
- output_schema = wrapped_schema
-
- # Add schema definitions if available and handle nullable fields in them
- # Only add $defs if we didn't resolve the $ref inline above
- if schema_definitions and "$ref" not in schema.copy():
- processed_defs = {}
- for def_name, def_schema in schema_definitions.items():
- # Only handle nullable fields for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
- if openapi_version and openapi_version.startswith("3.0"):
- processed_defs[def_name] = _handle_nullable_fields(def_schema)
- else:
- processed_defs[def_name] = def_schema
- output_schema["$defs"] = processed_defs
-
- # Use lightweight compression - prune additionalProperties and unused definitions
- if output_schema.get("additionalProperties") is False:
- output_schema.pop("additionalProperties")
-
- # Remove unused definitions (lightweight approach - just check direct $ref usage)
- if "$defs" in output_schema:
- used_refs = set()
-
- def find_refs_in_value(value):
- if isinstance(value, dict):
- if "$ref" in value and isinstance(value["$ref"], str):
- ref = value["$ref"]
- if ref.startswith("#/$defs/"):
- used_refs.add(ref.split("/")[-1])
- for v in value.values():
- find_refs_in_value(v)
- elif isinstance(value, list):
- for item in value:
- find_refs_in_value(item)
-
- # Find refs in the main schema (excluding $defs section)
- for key, value in output_schema.items():
- if key != "$defs":
- find_refs_in_value(value)
-
- # Remove unused definitions
- if used_refs:
- output_schema["$defs"] = {
- name: def_schema
- for name, def_schema in output_schema["$defs"].items() # type: ignore[index]
- if name in used_refs
- }
- else:
- output_schema.pop("$defs")
-
- # Adjust union types to handle overlapping unions
- output_schema = cast(dict[str, Any], _adjust_union_types(output_schema))
-
- return output_schema
diff --git a/src/fastmcp/experimental/utilities/openapi/README.md b/src/fastmcp/utilities/openapi/README.md
similarity index 82%
rename from src/fastmcp/experimental/utilities/openapi/README.md
rename to src/fastmcp/utilities/openapi/README.md
index c63bc7170..2f2a5f45f 100644
--- a/src/fastmcp/experimental/utilities/openapi/README.md
+++ b/src/fastmcp/utilities/openapi/README.md
@@ -1,10 +1,10 @@
-# OpenAPI Utilities (New Implementation)
+# OpenAPI Utilities
-This directory contains the next-generation OpenAPI integration utilities for FastMCP, designed to replace the legacy `openapi.py` implementation.
+This directory contains the OpenAPI integration utilities for FastMCP.
## Architecture Overview
-The new implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance.
+The implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance.
### Core Components
@@ -83,7 +83,7 @@ MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response
## Component Integration
-### Server Components (`/server/openapi_new/`)
+### Server Components (`/server/openapi/`)
1. **`OpenAPITool`** - Simplified tool implementation using RequestDirector
2. **`OpenAPIResource`** - Resource implementation with RequestDirector
@@ -104,7 +104,7 @@ All components use the same RequestDirector approach:
```python
import httpx
-from fastmcp.server.openapi_new import FastMCPOpenAPI
+from fastmcp.server.openapi import FastMCPOpenAPI
# OpenAPI spec (can be loaded from file/URL)
openapi_spec = {...}
@@ -124,7 +124,7 @@ async with httpx.AsyncClient() as client:
### Direct RequestDirector Usage
```python
-from fastmcp.experimental.utilities.openapi.director import RequestDirector
+from fastmcp.utilities.openapi.director import RequestDirector
from jsonschema_path import SchemaPath
# Create RequestDirector manually
@@ -141,7 +141,7 @@ async with httpx.AsyncClient() as client:
## Testing Strategy
-Tests are located in `/tests/server/openapi_new/`:
+Tests are located in `/tests/server/openapi/`:
### Test Categories
@@ -160,34 +160,6 @@ Tests are located in `/tests/server/openapi_new/`:
- **Performance Focus**: Test that initialization is fast and stateless
- **Behavioral Testing**: Verify OpenAPI compliance without implementation details
-## Migration Guide
-
-### From Legacy Implementation
-
-1. **Import Changes**:
- ```python
- # Old
- from fastmcp.server.openapi import FastMCPOpenAPI
-
- # New
- from fastmcp.server.openapi_new import FastMCPOpenAPI
- ```
-
-2. **Constructor**: Same interface, no changes needed
-
-3. **Automatic Benefits**:
- - Eliminates startup latency (100-200ms improvement)
- - Better OpenAPI compliance via openapi-core
- - Serverless-friendly performance characteristics
- - Simplified architecture without fallback complexity
-
-### Performance Improvements
-
-- **Cold Start**: Zero latency penalty for serverless deployments
-- **Memory Usage**: Lower memory footprint without generated client code
-- **Reliability**: No dynamic code generation failures
-- **Maintainability**: Simpler architecture with fewer moving parts
-
## Future Enhancements
### Planned Features
diff --git a/src/fastmcp/utilities/openapi/__init__.py b/src/fastmcp/utilities/openapi/__init__.py
new file mode 100644
index 000000000..f71bc7a6a
--- /dev/null
+++ b/src/fastmcp/utilities/openapi/__init__.py
@@ -0,0 +1,63 @@
+"""OpenAPI utilities for FastMCP - refactored for better maintainability."""
+
+# Import from models
+from .models import (
+ HTTPRoute,
+ HttpMethod,
+ JsonSchema,
+ ParameterInfo,
+ ParameterLocation,
+ RequestBodyInfo,
+ ResponseInfo,
+)
+
+# Import from parser
+from .parser import parse_openapi_to_http_routes
+
+# Import from formatters
+from .formatters import (
+ format_array_parameter,
+ format_deep_object_parameter,
+ format_description_with_responses,
+ format_json_for_description,
+ format_simple_description,
+ generate_example_from_schema,
+)
+
+# Import from schemas
+from .schemas import (
+ _combine_schemas,
+ extract_output_schema_from_responses,
+ clean_schema_for_display,
+ _make_optional_parameter_nullable,
+)
+
+# Import from json_schema_converter
+from .json_schema_converter import (
+ convert_openapi_schema_to_json_schema,
+ convert_schema_definitions,
+)
+
+# Export public symbols - maintaining backward compatibility
+__all__ = [
+ "HTTPRoute",
+ "HttpMethod",
+ "JsonSchema",
+ "ParameterInfo",
+ "ParameterLocation",
+ "RequestBodyInfo",
+ "ResponseInfo",
+ "_combine_schemas",
+ "_make_optional_parameter_nullable",
+ "clean_schema_for_display",
+ "convert_openapi_schema_to_json_schema",
+ "convert_schema_definitions",
+ "extract_output_schema_from_responses",
+ "format_array_parameter",
+ "format_deep_object_parameter",
+ "format_description_with_responses",
+ "format_json_for_description",
+ "format_simple_description",
+ "generate_example_from_schema",
+ "parse_openapi_to_http_routes",
+]
diff --git a/src/fastmcp/experimental/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py
similarity index 100%
rename from src/fastmcp/experimental/utilities/openapi/director.py
rename to src/fastmcp/utilities/openapi/director.py
diff --git a/src/fastmcp/experimental/utilities/openapi/formatters.py b/src/fastmcp/utilities/openapi/formatters.py
similarity index 97%
rename from src/fastmcp/experimental/utilities/openapi/formatters.py
rename to src/fastmcp/utilities/openapi/formatters.py
index 0b3e3f899..27580fcdd 100644
--- a/src/fastmcp/experimental/utilities/openapi/formatters.py
+++ b/src/fastmcp/utilities/openapi/formatters.py
@@ -67,13 +67,13 @@ def format_deep_object_parameter(
param_value: dict, parameter_name: str
) -> dict[str, str]:
"""
- Format a dictionary parameter for deepObject style serialization.
+ Format a dictionary parameter for deep-object style serialization.
According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
object properties as separate query parameters with bracket notation.
- For example: {"id": "123", "type": "user"} becomes:
- param[id]=123¶m[type]=user
+ For example, `{"id": "123", "type": "user"}` becomes
+ `param[id]=123¶m[type]=user`.
Args:
param_value: Dictionary value to format
@@ -84,7 +84,7 @@ def format_deep_object_parameter(
"""
if not isinstance(param_value, dict):
logger.warning(
- f"deepObject style parameter '{parameter_name}' expected dict, got {type(param_value)}"
+ f"Deep-object style parameter '{parameter_name}' expected dict, got {type(param_value)}"
)
return {}
@@ -181,7 +181,7 @@ def generate_example_from_schema(schema: JsonSchema | None) -> Any:
def format_json_for_description(data: Any, indent: int = 2) -> str:
- """Formats Python data as a JSON string block for markdown."""
+ """Formats Python data as a JSON string block for Markdown."""
try:
json_str = json.dumps(data, indent=indent)
return f"```json\n{json_str}\n```"
diff --git a/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py b/src/fastmcp/utilities/openapi/json_schema_converter.py
similarity index 99%
rename from src/fastmcp/experimental/utilities/openapi/json_schema_converter.py
rename to src/fastmcp/utilities/openapi/json_schema_converter.py
index 23e4f6e2d..c92b2f394 100644
--- a/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py
+++ b/src/fastmcp/utilities/openapi/json_schema_converter.py
@@ -60,7 +60,7 @@ def convert_openapi_schema_to_json_schema(
convert_one_of_to_any_of: Whether to convert oneOf to anyOf
Returns:
- JSON Schema compatible dictionary
+ JSON Schema-compatible dictionary
"""
if not isinstance(schema, dict):
return schema
diff --git a/src/fastmcp/experimental/utilities/openapi/models.py b/src/fastmcp/utilities/openapi/models.py
similarity index 100%
rename from src/fastmcp/experimental/utilities/openapi/models.py
rename to src/fastmcp/utilities/openapi/models.py
diff --git a/src/fastmcp/experimental/utilities/openapi/parser.py b/src/fastmcp/utilities/openapi/parser.py
similarity index 100%
rename from src/fastmcp/experimental/utilities/openapi/parser.py
rename to src/fastmcp/utilities/openapi/parser.py
diff --git a/src/fastmcp/experimental/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py
similarity index 100%
rename from src/fastmcp/experimental/utilities/openapi/schemas.py
rename to src/fastmcp/utilities/openapi/schemas.py
diff --git a/tests/client/test_openapi_legacy.py b/tests/client/test_openapi.py
similarity index 96%
rename from tests/client/test_openapi_legacy.py
rename to tests/client/test_openapi.py
index b9fd8a18b..ac420aab6 100644
--- a/tests/client/test_openapi_legacy.py
+++ b/tests/client/test_openapi.py
@@ -9,7 +9,8 @@ from fastmcp.server.openapi import MCPType, RouteMap
from fastmcp.utilities.tests import run_server_async
-def fastmcp_server_for_headers() -> FastMCP:
+def create_fastmcp_server_for_headers() -> FastMCP:
+ """Create a FastMCP server from FastAPI app with experimental parser."""
app = FastAPI()
@app.get("/headers")
@@ -45,7 +46,7 @@ def fastmcp_server_for_headers() -> FastMCP:
@pytest.fixture
async def shttp_server():
"""Start a test server with StreamableHttp transport."""
- server = fastmcp_server_for_headers()
+ server = create_fastmcp_server_for_headers()
async with run_server_async(server, transport="http") as url:
yield url
@@ -53,7 +54,7 @@ async def shttp_server():
@pytest.fixture
async def sse_server():
"""Start a test server with SSE transport."""
- server = fastmcp_server_for_headers()
+ server = create_fastmcp_server_for_headers()
async with run_server_async(server, transport="sse") as url:
yield url
@@ -180,6 +181,7 @@ async def test_client_with_excluded_header_is_ignored(sse_server: str):
assert headers["host"] == "fastapi"
+@pytest.mark.flaky(retries=2, delay=1)
async def test_client_headers_proxy(proxy_server: str):
"""
Test that client headers are passed through the proxy to the remove server.
diff --git a/tests/client/test_openapi_experimental.py b/tests/client/test_openapi_experimental.py
deleted file mode 100644
index e63ab6c79..000000000
--- a/tests/client/test_openapi_experimental.py
+++ /dev/null
@@ -1,194 +0,0 @@
-import json
-
-import pytest
-from fastapi import FastAPI, Request
-
-from fastmcp import Client, FastMCP
-from fastmcp.client.transports import SSETransport, StreamableHttpTransport
-from fastmcp.experimental.server.openapi import MCPType, RouteMap
-from fastmcp.utilities.tests import run_server_async, temporary_settings
-
-
-def create_fastmcp_server_for_headers() -> FastMCP:
- """Create a FastMCP server from FastAPI app with experimental parser."""
- app = FastAPI()
-
- @app.get("/headers")
- def get_headers(request: Request):
- return request.headers
-
- @app.get("/headers/{header_name}")
- def get_header_by_name(header_name: str, request: Request):
- return request.headers[header_name]
-
- @app.post("/headers")
- def post_headers(request: Request):
- return request.headers
-
- mcp = FastMCP.from_fastapi(
- app,
- httpx_client_kwargs={"headers": {"x-server-header": "test-abc"}},
- route_maps=[
- # GET requests with path parameters go to ResourceTemplate
- RouteMap(
- methods=["GET"],
- pattern=r".*\{.*\}.*",
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- ),
- # GET requests without path parameters go to Resource
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- ],
- )
-
- return mcp
-
-
-@pytest.fixture
-async def shttp_server():
- """Start a test server with StreamableHttp transport."""
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- server = create_fastmcp_server_for_headers()
- async with run_server_async(server, transport="http") as url:
- yield url
-
-
-@pytest.fixture
-async def sse_server():
- """Start a test server with SSE transport."""
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- server = create_fastmcp_server_for_headers()
- async with run_server_async(server, transport="sse") as url:
- yield url
-
-
-@pytest.fixture
-async def proxy_server(shttp_server: str):
- """Start a proxy server."""
- proxy = FastMCP.as_proxy(StreamableHttpTransport(shttp_server))
- async with run_server_async(proxy, transport="http") as url:
- yield url
-
-
-async def test_fastapi_client_headers_streamable_http_resource(shttp_server: str):
- async with Client(transport=StreamableHttpTransport(shttp_server)) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["x-server-header"] == "test-abc"
-
-
-async def test_fastapi_client_headers_sse_resource(sse_server: str):
- async with Client(transport=SSETransport(sse_server)) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["x-server-header"] == "test-abc"
-
-
-async def test_fastapi_client_headers_streamable_http_tool(shttp_server: str):
- async with Client(transport=StreamableHttpTransport(shttp_server)) as client:
- result = await client.call_tool("post_headers_headers_post")
- headers: dict[str, str] = result.data
- assert headers["x-server-header"] == "test-abc"
-
-
-async def test_fastapi_client_headers_sse_tool(sse_server: str):
- async with Client(transport=SSETransport(sse_server)) as client:
- result = await client.call_tool("post_headers_headers_post")
- headers: dict[str, str] = result.data
- assert headers["x-server-header"] == "test-abc"
-
-
-async def test_client_headers_sse_resource(sse_server: str):
- async with Client(
- transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
- ) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["x-test"] == "test-123"
-
-
-async def test_client_headers_shttp_resource(shttp_server: str):
- async with Client(
- transport=StreamableHttpTransport(shttp_server, headers={"X-TEST": "test-123"})
- ) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["x-test"] == "test-123"
-
-
-async def test_client_headers_sse_resource_template(sse_server: str):
- async with Client(
- transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
- ) as client:
- result = await client.read_resource(
- "resource://get_header_by_name_headers/x-test"
- )
- header = json.loads(result[0].text) # type: ignore[attr-defined]
- assert header == "test-123"
-
-
-async def test_client_headers_shttp_resource_template(shttp_server: str):
- async with Client(
- transport=StreamableHttpTransport(shttp_server, headers={"X-TEST": "test-123"})
- ) as client:
- result = await client.read_resource(
- "resource://get_header_by_name_headers/x-test"
- )
- header = json.loads(result[0].text) # type: ignore[attr-defined]
- assert header == "test-123"
-
-
-async def test_client_headers_sse_tool(sse_server: str):
- async with Client(
- transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
- ) as client:
- result = await client.call_tool("post_headers_headers_post")
- headers: dict[str, str] = result.data
- assert headers["x-test"] == "test-123"
-
-
-async def test_client_headers_shttp_tool(shttp_server: str):
- async with Client(
- transport=StreamableHttpTransport(shttp_server, headers={"X-TEST": "test-123"})
- ) as client:
- result = await client.call_tool("post_headers_headers_post")
- headers: dict[str, str] = result.data
- assert headers["x-test"] == "test-123"
-
-
-async def test_client_overrides_server_headers(shttp_server: str):
- async with Client(
- transport=StreamableHttpTransport(
- shttp_server, headers={"x-server-header": "test-client"}
- )
- ) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["x-server-header"] == "test-client"
-
-
-async def test_client_with_excluded_header_is_ignored(sse_server: str):
- async with Client(
- transport=SSETransport(
- sse_server,
- headers={
- "x-server-header": "test-client",
- "host": "1.2.3.4",
- "not-host": "1.2.3.4",
- },
- )
- ) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["not-host"] == "1.2.3.4"
- assert headers["host"] == "fastapi"
-
-
-@pytest.mark.flaky(retries=2, delay=1)
-async def test_client_headers_proxy(proxy_server: str):
- """
- Test that client headers are passed through the proxy to the remove server.
- """
- async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
- result = await client.read_resource("resource://get_headers_headers_get")
- headers = json.loads(result[0].text) # type: ignore[attr-defined]
- assert headers["x-server-header"] == "test-abc"
diff --git a/tests/deprecated/test_openapi_deprecations.py b/tests/deprecated/test_openapi_deprecations.py
new file mode 100644
index 000000000..7d1e3904e
--- /dev/null
+++ b/tests/deprecated/test_openapi_deprecations.py
@@ -0,0 +1,73 @@
+"""Tests for OpenAPI-related deprecations in 2.14."""
+
+import warnings
+
+import pytest
+
+import fastmcp
+
+pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
+
+
+class TestEnableNewOpenAPIParserDeprecation:
+ """Test enable_new_openapi_parser setting deprecation."""
+
+ def test_setting_true_emits_warning(self):
+ """Setting enable_new_openapi_parser=True should emit deprecation warning."""
+ with pytest.warns(
+ DeprecationWarning,
+ match=r"enable_new_openapi_parser is deprecated.*now the default",
+ ):
+ fastmcp.settings.experimental.enable_new_openapi_parser = True
+
+ def test_setting_false_no_warning(self):
+ """Setting enable_new_openapi_parser=False should not emit warning."""
+ with warnings.catch_warnings(record=True) as recorded:
+ warnings.simplefilter("always")
+ fastmcp.settings.experimental.enable_new_openapi_parser = False
+
+ deprecation_warnings = [
+ w for w in recorded if issubclass(w.category, DeprecationWarning)
+ ]
+ assert len(deprecation_warnings) == 0
+
+
+class TestExperimentalOpenAPIImportDeprecation:
+ """Test experimental OpenAPI import path deprecations."""
+
+ def test_experimental_server_openapi_import_warns(self):
+ """Importing from fastmcp.experimental.server.openapi should warn."""
+ with pytest.warns(
+ DeprecationWarning,
+ match=r"Importing from fastmcp\.experimental\.server\.openapi is deprecated",
+ ):
+ from fastmcp.experimental.server.openapi import FastMCPOpenAPI # noqa: F401
+
+ def test_experimental_utilities_openapi_import_warns(self):
+ """Importing from fastmcp.experimental.utilities.openapi should warn."""
+ with pytest.warns(
+ DeprecationWarning,
+ match=r"Importing from fastmcp\.experimental\.utilities\.openapi is deprecated",
+ ):
+ from fastmcp.experimental.utilities.openapi import HTTPRoute # noqa: F401
+
+ def test_experimental_imports_resolve_to_same_classes(self):
+ """Experimental imports should resolve to the same classes as main imports."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+
+ from fastmcp.experimental.server.openapi import (
+ FastMCPOpenAPI as ExpFastMCPOpenAPI,
+ )
+ from fastmcp.experimental.server.openapi import MCPType as ExpMCPType
+ from fastmcp.experimental.server.openapi import RouteMap as ExpRouteMap
+ from fastmcp.experimental.utilities.openapi import (
+ HTTPRoute as ExpHTTPRoute,
+ )
+ from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
+ from fastmcp.utilities.openapi import HTTPRoute
+
+ assert FastMCPOpenAPI is ExpFastMCPOpenAPI
+ assert RouteMap is ExpRouteMap
+ assert MCPType is ExpMCPType
+ assert HTTPRoute is ExpHTTPRoute
diff --git a/tests/deprecated/test_route_type_ignore.py b/tests/deprecated/test_route_type_ignore.py
deleted file mode 100644
index 06204ecf4..000000000
--- a/tests/deprecated/test_route_type_ignore.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""Tests for the deprecated RouteType.IGNORE."""
-
-import warnings
-
-import httpx
-import pytest
-
-from fastmcp.server.openapi import (
- FastMCPOpenAPI,
- MCPType,
- RouteMap,
- RouteType,
-)
-
-# reset deprecation warnings for this module
-pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
-
-
-def test_route_type_ignore_deprecation_warning():
- """Test that using RouteType.IGNORE emits a deprecation warning."""
- # Let's manually capture the warnings
-
- # Record all warnings
- with warnings.catch_warnings(record=True) as recorded:
- # Make sure warnings are always triggered
- warnings.simplefilter("always")
-
- # Create a RouteMap with RouteType.IGNORE
- route_map = RouteMap(
- methods=["GET"], pattern=r"^/analytics$", route_type=RouteType.IGNORE
- )
-
- # Check for the expected warnings in the recorded warnings
- route_type_warning = False
- ignore_warning = False
-
- for w in recorded:
- if issubclass(w.category, DeprecationWarning):
- message = str(w.message)
- if "route_type' parameter is deprecated" in message:
- route_type_warning = True
- if "RouteType.IGNORE is deprecated" in message:
- ignore_warning = True
-
- # Make sure both warnings were triggered
- assert route_type_warning, "Missing 'route_type' deprecation warning"
- assert ignore_warning, "Missing 'RouteType.IGNORE' deprecation warning"
-
- # Verify that RouteType.IGNORE was converted to MCPType.EXCLUDE
- assert route_map.mcp_type == MCPType.EXCLUDE
-
-
-class TestRouteTypeIgnoreDeprecation:
- """Test class for the deprecated RouteType.IGNORE."""
-
- @pytest.fixture
- def basic_openapi_spec(self) -> dict:
- """Create a simple OpenAPI spec for testing."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/items": {
- "get": {
- "operationId": "get_items",
- "summary": "Get all items",
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/analytics": {
- "get": {
- "operationId": "get_analytics",
- "summary": "Get analytics data",
- "responses": {"200": {"description": "Success"}},
- }
- },
- },
- }
-
- @pytest.fixture
- async def mock_client(self) -> httpx.AsyncClient:
- """Create a mock client for testing."""
-
- async def _responder(request):
- return httpx.Response(200, json={"success": True})
-
- return httpx.AsyncClient(transport=httpx.MockTransport(_responder))
-
- async def test_route_type_ignore_conversion(self, basic_openapi_spec, mock_client):
- """Test that routes with RouteType.IGNORE are properly excluded."""
- # Capture the deprecation warning without checking the exact message
- with pytest.warns(DeprecationWarning):
- server = FastMCPOpenAPI(
- openapi_spec=basic_openapi_spec,
- client=mock_client,
- route_maps=[
- # Use the deprecated RouteType.IGNORE
- RouteMap(
- methods=["GET"],
- pattern=r"^/analytics$",
- route_type=RouteType.IGNORE,
- ),
- # Make everything else a resource
- RouteMap(
- methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
- ),
- ],
- )
-
- # Check that the analytics route was excluded (converted from IGNORE to EXCLUDE)
- resources = await server.get_resources()
- resource_uris = [str(r.uri) for r in resources.values()]
-
- # Analytics should be excluded
- assert "resource://get_items" in resource_uris
- assert "resource://get_analytics" not in resource_uris
diff --git a/tests/experimental/openapi_parser/README.md b/tests/experimental/openapi_parser/README.md
deleted file mode 100644
index 2f7302546..000000000
--- a/tests/experimental/openapi_parser/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# OpenAPI Parser Experiment
-
-Performance-optimized OpenAPI parser with better transitive reference resolution.
-
-Enabled via `experimental.enable_new_openapi_parser = True`
\ No newline at end of file
diff --git a/tests/experimental/openapi_parser/__init__.py b/tests/experimental/openapi_parser/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/experimental/openapi_parser/conftest.py b/tests/experimental/openapi_parser/conftest.py
deleted file mode 100644
index 3a6b24101..000000000
--- a/tests/experimental/openapi_parser/conftest.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Shared fixtures for openapi_new utilities tests."""
-
-import pytest
-
-from fastmcp.utilities.tests import temporary_settings
-
-
-@pytest.fixture(autouse=True)
-def use_new_openapi_parser():
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- yield
diff --git a/tests/experimental/openapi_parser/server/__init__.py b/tests/experimental/openapi_parser/server/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/experimental/openapi_parser/server/openapi/__init__.py b/tests/experimental/openapi_parser/server/openapi/__init__.py
deleted file mode 100644
index b862f61a3..000000000
--- a/tests/experimental/openapi_parser/server/openapi/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for openapi_new server components."""
diff --git a/tests/experimental/openapi_parser/server/openapi/test_deepobject_style.py b/tests/experimental/openapi_parser/server/openapi/test_deepobject_style.py
deleted file mode 100644
index 54327b1ba..000000000
--- a/tests/experimental/openapi_parser/server/openapi/test_deepobject_style.py
+++ /dev/null
@@ -1,333 +0,0 @@
-"""Tests for deepObject style parameter handling in openapi_new."""
-
-import httpx
-import pytest
-
-from fastmcp.client import Client
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
-
-
-class TestDeepObjectStyle:
- """Test deepObject style parameter handling in openapi_new."""
-
- @pytest.fixture
- def deepobject_spec(self):
- """OpenAPI spec with deepObject style parameters."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "DeepObject Test API", "version": "1.0.0"},
- "servers": [{"url": "https://api.example.com"}],
- "paths": {
- "/surveys": {
- "get": {
- "operationId": "get_surveys",
- "summary": "Get surveys with deepObject filtering",
- "parameters": [
- {
- "name": "target",
- "in": "query",
- "required": False,
- "style": "deepObject",
- "explode": True,
- "schema": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "description": "Target ID",
- },
- "type": {
- "type": "string",
- "enum": ["location", "organisation"],
- "description": "Target type",
- },
- },
- "required": ["type", "id"],
- },
- "description": "Target object for filtering",
- },
- {
- "name": "filters",
- "in": "query",
- "required": False,
- "style": "deepObject",
- "explode": True,
- "schema": {
- "type": "object",
- "properties": {
- "status": {"type": "string"},
- "category": {"type": "string"},
- "priority": {"type": "integer"},
- },
- },
- "description": "Additional filters",
- },
- {
- "name": "compact",
- "in": "query",
- "required": False,
- "style": "deepObject",
- "explode": False,
- "schema": {
- "type": "object",
- "properties": {
- "format": {"type": "string"},
- "level": {"type": "integer"},
- },
- },
- "description": "Compact format options (explode=false)",
- },
- ],
- "responses": {
- "200": {
- "description": "Survey list",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "surveys": {
- "type": "array",
- "items": {"type": "object"},
- },
- "total": {"type": "integer"},
- },
- }
- }
- },
- }
- },
- }
- },
- "/users/{id}/preferences": {
- "patch": {
- "operationId": "update_preferences",
- "summary": "Update user preferences with deepObject in body",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "integer"},
- }
- ],
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "preferences": {
- "type": "object",
- "properties": {
- "theme": {"type": "string"},
- "notifications": {
- "type": "object",
- "properties": {
- "email": {
- "type": "boolean"
- },
- "push": {"type": "boolean"},
- "frequency": {
- "type": "string"
- },
- },
- },
- "privacy": {
- "type": "object",
- "properties": {
- "profile_visible": {
- "type": "boolean"
- },
- "analytics": {
- "type": "boolean"
- },
- },
- },
- },
- "description": "Nested preference object",
- }
- },
- "required": ["preferences"],
- }
- }
- },
- },
- "responses": {
- "200": {
- "description": "Preferences updated",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "success": {"type": "boolean"}
- },
- }
- }
- },
- }
- },
- }
- },
- },
- }
-
- async def test_deepobject_style_parsing_from_spec(self, deepobject_spec):
- """Test that deepObject style parameters are correctly parsed from OpenAPI spec."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=deepobject_spec,
- client=client,
- name="DeepObject Test Server",
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
-
- # Find the surveys tool
- surveys_tool = next(
- tool for tool in tools if tool.name == "get_surveys"
- )
- assert surveys_tool is not None
-
- # Check that deepObject parameters are included in schema
- params = surveys_tool.inputSchema
- properties = params["properties"]
-
- # Should have the deepObject parameters
- assert "target" in properties
- assert "filters" in properties
- assert "compact" in properties
-
- # Check that target parameter is present
- # (Exact schema structure may vary based on implementation)
- target_param = properties["target"]
- # Should have some structure, exact format may vary
- assert target_param is not None
-
- async def test_deepobject_explode_true_handling(self, deepobject_spec):
- """Test deepObject with explode=true parameter handling."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=deepobject_spec,
- client=client,
- name="DeepObject Test Server",
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
- surveys_tool = next(
- tool for tool in tools if tool.name == "get_surveys"
- )
-
- # Check that explode=true parameters are properly structured
- params = surveys_tool.inputSchema
- properties = params["properties"]
-
- # Target parameter with explode=true should allow individual property access
- target_properties = properties["target"]["properties"]
- assert "id" in target_properties
- assert "type" in target_properties
- assert target_properties["type"]["enum"] == ["location", "organisation"]
-
- async def test_deepobject_explode_false_handling(self, deepobject_spec):
- """Test deepObject with explode=false parameter handling."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=deepobject_spec,
- client=client,
- name="DeepObject Test Server",
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
- surveys_tool = next(
- tool for tool in tools if tool.name == "get_surveys"
- )
-
- # Check that explode=false parameters are handled
- params = surveys_tool.inputSchema
- properties = params["properties"]
-
- # Compact parameter with explode=false should still be present and valid
- assert "compact" in properties
- compact_param = properties["compact"]
- # Check that it's a valid parameter (exact structure may vary)
- assert compact_param is not None
- # If it has a type, it should be object
- if "type" in compact_param:
- assert compact_param["type"] == "object"
-
- async def test_nested_object_structure_in_request_body(self, deepobject_spec):
- """Test nested object structures in request body are preserved."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=deepobject_spec,
- client=client,
- name="DeepObject Test Server",
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
-
- # Find the preferences tool
- prefs_tool = next(
- tool for tool in tools if tool.name == "update_preferences"
- )
- assert prefs_tool is not None
-
- # Check that nested object structure is preserved
- params = prefs_tool.inputSchema
- properties = params["properties"]
-
- # Should have path parameter
- assert "id" in properties
-
- # Should have preferences object
- assert "preferences" in properties
- prefs_param = properties["preferences"]
- assert prefs_param["type"] == "object"
-
- # Check nested structure
- prefs_props = prefs_param["properties"]
- assert "theme" in prefs_props
- assert "notifications" in prefs_props
- assert "privacy" in prefs_props
-
- # Check deeply nested objects
- notifications = prefs_props["notifications"]
- assert notifications["type"] == "object"
- notif_props = notifications["properties"]
- assert "email" in notif_props
- assert "push" in notif_props
- assert "frequency" in notif_props
-
- async def test_deepobject_tool_functionality(self, deepobject_spec):
- """Test that tools with deepObject parameters maintain basic functionality."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=deepobject_spec,
- client=client,
- name="DeepObject Test Server",
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
-
- # Should successfully create tools with deepObject parameters
- assert len(tools) == 2
-
- tool_names = {tool.name for tool in tools}
- assert "get_surveys" in tool_names
- assert "update_preferences" in tool_names
-
- # All tools should have valid schemas
- for tool in tools:
- assert tool.inputSchema is not None
- assert tool.inputSchema["type"] == "object"
- assert "properties" in tool.inputSchema
-
- # Should have some properties
- assert len(tool.inputSchema["properties"]) > 0
diff --git a/tests/experimental/openapi_parser/server/openapi/test_parameter_collisions.py b/tests/experimental/openapi_parser/server/openapi/test_parameter_collisions.py
deleted file mode 100644
index fb87ffb5a..000000000
--- a/tests/experimental/openapi_parser/server/openapi/test_parameter_collisions.py
+++ /dev/null
@@ -1,212 +0,0 @@
-"""Tests for parameter collision handling in openapi_new."""
-
-import httpx
-import pytest
-
-from fastmcp.client import Client
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
-
-
-class TestParameterCollisions:
- """Test parameter name collisions between different locations (path, query, body)."""
-
- @pytest.fixture
- def collision_spec(self):
- """OpenAPI spec with parameter name collisions."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Collision Test API", "version": "1.0.0"},
- "servers": [{"url": "https://api.example.com"}],
- "paths": {
- "/users/{id}": {
- "put": {
- "operationId": "update_user",
- "summary": "Update user with collision between path and body",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "integer"},
- "description": "User ID in path",
- }
- ],
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {
- "type": "integer",
- "description": "User ID in body (different from path)",
- },
- "name": {
- "type": "string",
- "description": "User name",
- },
- "email": {
- "type": "string",
- "description": "User email",
- },
- },
- "required": ["name", "email"],
- }
- }
- },
- },
- "responses": {
- "200": {
- "description": "User updated",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- "email": {"type": "string"},
- },
- }
- }
- },
- }
- },
- }
- },
- "/search": {
- "get": {
- "operationId": "search_with_collision",
- "summary": "Search with query and header collision",
- "parameters": [
- {
- "name": "query",
- "in": "query",
- "required": True,
- "schema": {"type": "string"},
- "description": "Search query parameter",
- },
- {
- "name": "query",
- "in": "header",
- "required": False,
- "schema": {"type": "string"},
- "description": "Search query in header",
- },
- ],
- "responses": {
- "200": {
- "description": "Search results",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "results": {
- "type": "array",
- "items": {"type": "object"},
- }
- },
- }
- }
- },
- }
- },
- }
- },
- },
- }
-
- async def test_path_body_collision_handling(self, collision_spec):
- """Test that path and body parameters with same name are handled correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=collision_spec, client=client, name="Collision Test Server"
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
-
- # Find the update user tool
- update_tool = next(tool for tool in tools if tool.name == "update_user")
- assert update_tool is not None
-
- # Check that both path and body 'id' parameters are included
- params = update_tool.inputSchema
- properties = params["properties"]
-
- # Should have both path ID and body ID (with potential suffixing)
- # The implementation should handle this collision by suffixing one of them
- assert "id" in properties # One version of id
-
- # Check for suffixed versions or verify both exist somehow
- # The exact handling depends on implementation, but both should be accessible
- param_names = list(properties.keys())
- id_params = [name for name in param_names if "id" in name]
- assert len(id_params) >= 1 # At least one id parameter
-
- # Should also have other body parameters
- assert "name" in properties
- assert "email" in properties
-
- # Required fields should include path parameter and required body fields
- required = params.get("required", [])
- assert "name" in required
- assert "email" in required
- # Path parameter should be required (may be suffixed)
- id_required = any("id" in req for req in required)
- assert id_required
-
- async def test_query_header_collision_handling(self, collision_spec):
- """Test that query and header parameters with same name are handled correctly."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=collision_spec, client=client, name="Collision Test Server"
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
-
- # Find the search tool
- search_tool = next(
- tool for tool in tools if tool.name == "search_with_collision"
- )
- assert search_tool is not None
-
- # Check that both query and header 'query' parameters are handled
- params = search_tool.inputSchema
- properties = params["properties"]
-
- # Should handle the collision somehow (suffixing or other mechanism)
- param_names = list(properties.keys())
- query_params = [name for name in param_names if "query" in name]
- assert len(query_params) >= 1 # At least one query parameter
-
- # Required should include the required query parameter
- required = params.get("required", [])
- query_required = any("query" in req for req in required)
- assert query_required
-
- async def test_collision_resolution_maintains_functionality(self, collision_spec):
- """Test that collision resolution doesn't break basic tool functionality."""
- async with httpx.AsyncClient(base_url="https://api.example.com") as client:
- server = FastMCPOpenAPI(
- openapi_spec=collision_spec, client=client, name="Collision Test Server"
- )
-
- async with Client(server) as mcp_client:
- tools = await mcp_client.list_tools()
-
- # Should successfully create tools despite collisions
- assert len(tools) == 2
-
- tool_names = {tool.name for tool in tools}
- assert "update_user" in tool_names
- assert "search_with_collision" in tool_names
-
- # Tools should have valid schemas
- for tool in tools:
- assert tool.inputSchema is not None
- assert tool.inputSchema["type"] == "object"
- assert "properties" in tool.inputSchema
diff --git a/tests/experimental/openapi_parser/utilities/__init__.py b/tests/experimental/openapi_parser/utilities/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/experimental/openapi_parser/utilities/openapi/__init__.py b/tests/experimental/openapi_parser/utilities/openapi/__init__.py
deleted file mode 100644
index 65fb50b8d..000000000
--- a/tests/experimental/openapi_parser/utilities/openapi/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for openapi_new utilities."""
diff --git a/tests/experimental/openapi_parser/utilities/openapi/conftest.py b/tests/experimental/openapi_parser/utilities/openapi/conftest.py
deleted file mode 100644
index b7158dd1c..000000000
--- a/tests/experimental/openapi_parser/utilities/openapi/conftest.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""Shared fixtures for openapi_new utilities tests."""
-
-import pytest
-
-
-@pytest.fixture
-def basic_openapi_30_spec():
- """Basic OpenAPI 3.0 spec for testing."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "servers": [{"url": "https://api.example.com"}],
- "paths": {
- "/users/{id}": {
- "get": {
- "operationId": "get_user",
- "summary": "Get user by ID",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "integer"},
- }
- ],
- "responses": {
- "200": {
- "description": "User retrieved successfully",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- }
- }
- },
- }
- },
- }
- }
- },
- }
-
-
-@pytest.fixture
-def basic_openapi_31_spec():
- """Basic OpenAPI 3.1 spec for testing."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "servers": [{"url": "https://api.example.com"}],
- "paths": {
- "/users/{id}": {
- "get": {
- "operationId": "get_user",
- "summary": "Get user by ID",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "integer"},
- }
- ],
- "responses": {
- "200": {
- "description": "User retrieved successfully",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- }
- }
- },
- }
- },
- }
- }
- },
- }
-
-
-@pytest.fixture
-def collision_spec():
- """OpenAPI spec with parameter name collisions."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Collision Test API", "version": "1.0.0"},
- "paths": {
- "/users/{id}": {
- "put": {
- "operationId": "update_user",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "integer"},
- }
- ],
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- "required": ["name"],
- }
- }
- },
- },
- "responses": {"200": {"description": "Updated"}},
- }
- }
- },
- }
-
-
-@pytest.fixture
-def deepobject_spec():
- """OpenAPI spec with deepObject parameter style."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "DeepObject Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search",
- "parameters": [
- {
- "name": "filter",
- "in": "query",
- "required": False,
- "style": "deepObject",
- "explode": True,
- "schema": {
- "type": "object",
- "properties": {
- "category": {"type": "string"},
- "price": {
- "type": "object",
- "properties": {
- "min": {"type": "number"},
- "max": {"type": "number"},
- },
- },
- },
- },
- }
- ],
- "responses": {"200": {"description": "Search results"}},
- }
- }
- },
- }
-
-
-@pytest.fixture
-def complex_spec():
- """Complex OpenAPI spec with multiple parameter types."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Complex API", "version": "1.0.0"},
- "paths": {
- "/items/{id}": {
- "patch": {
- "operationId": "update_item",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "string"},
- },
- {
- "name": "version",
- "in": "query",
- "required": False,
- "schema": {"type": "integer", "default": 1},
- },
- {
- "name": "X-Client-Version",
- "in": "header",
- "required": False,
- "schema": {"type": "string"},
- },
- ],
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "title": {"type": "string"},
- "description": {"type": "string"},
- "tags": {
- "type": "array",
- "items": {"type": "string"},
- },
- },
- "required": ["title"],
- }
- }
- },
- },
- "responses": {"200": {"description": "Item updated"}},
- }
- }
- },
- }
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_legacy_compatibility.py b/tests/experimental/openapi_parser/utilities/openapi/test_legacy_compatibility.py
deleted file mode 100644
index 61eeafe25..000000000
--- a/tests/experimental/openapi_parser/utilities/openapi/test_legacy_compatibility.py
+++ /dev/null
@@ -1,333 +0,0 @@
-"""Tests to ensure new OpenAPI implementation matches legacy behavior exactly."""
-
-import pytest
-
-from fastmcp.experimental.utilities.openapi.models import (
- HTTPRoute,
- ParameterInfo,
- RequestBodyInfo,
-)
-from fastmcp.experimental.utilities.openapi.schemas import (
- _combine_schemas_and_map_params,
-)
-from fastmcp.utilities.openapi import HTTPRoute as LegacyHTTPRoute
-from fastmcp.utilities.openapi import ParameterInfo as LegacyParameterInfo
-from fastmcp.utilities.openapi import RequestBodyInfo as LegacyRequestBodyInfo
-from fastmcp.utilities.openapi import _combine_schemas as legacy_combine_schemas
-
-
-class TestLegacyCompatibility:
- """Test that new implementation produces identical schemas to legacy."""
-
- def test_optional_parameter_nullable_behavior(self):
- """Test that optional parameters get anyOf with null, required don't."""
- # Legacy route
- legacy_route = LegacyHTTPRoute(
- method="GET",
- path="/test",
- parameters=[
- LegacyParameterInfo(
- name="required_param",
- location="query",
- required=True,
- schema={"type": "string"},
- ),
- LegacyParameterInfo(
- name="optional_param",
- location="query",
- required=False,
- schema={"type": "string"},
- ),
- ],
- request_body=None,
- responses={},
- summary="Test endpoint",
- schema_definitions={},
- )
-
- # New route (equivalent)
- new_route = HTTPRoute(
- method="GET",
- path="/test",
- operation_id="test_op",
- parameters=[
- ParameterInfo(
- name="required_param",
- location="query",
- required=True,
- schema={"type": "string"},
- ),
- ParameterInfo(
- name="optional_param",
- location="query",
- required=False,
- schema={"type": "string"},
- ),
- ],
- )
-
- # Generate schemas
- legacy_schema = legacy_combine_schemas(legacy_route)
- new_schema, _ = _combine_schemas_and_map_params(new_route)
-
- # Required parameter should have simple type
- assert legacy_schema["properties"]["required_param"]["type"] == "string"
- assert new_schema["properties"]["required_param"]["type"] == "string"
- assert "anyOf" not in legacy_schema["properties"]["required_param"]
- assert "anyOf" not in new_schema["properties"]["required_param"]
-
- # Both implementations now correctly preserve original schema
- # Neither should make optional parameters nullable - they can simply be omitted
- assert "anyOf" not in legacy_schema["properties"]["optional_param"]
- assert "anyOf" not in new_schema["properties"]["optional_param"]
- assert legacy_schema["properties"]["optional_param"]["type"] == "string"
- assert new_schema["properties"]["optional_param"]["type"] == "string"
-
- # Required lists should match
- assert set(legacy_schema["required"]) == set(new_schema["required"])
- assert "required_param" in legacy_schema["required"]
- assert "optional_param" not in legacy_schema["required"]
-
- def test_parameter_collision_handling(self):
- """Test that parameter collisions are handled identically."""
- # Legacy route with collision (path param 'id' and body property 'id')
- legacy_route = LegacyHTTPRoute(
- method="PUT",
- path="/users/{id}",
- parameters=[
- LegacyParameterInfo(
- name="id",
- location="path",
- required=True,
- schema={"type": "integer"},
- )
- ],
- request_body=LegacyRequestBodyInfo(
- required=True,
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- "required": ["name"],
- }
- },
- ),
- responses={},
- summary="Update user",
- schema_definitions={},
- )
-
- # New route (equivalent)
- new_route = HTTPRoute(
- method="PUT",
- path="/users/{id}",
- operation_id="update_user",
- parameters=[
- ParameterInfo(
- name="id",
- location="path",
- required=True,
- schema={"type": "integer"},
- )
- ],
- request_body=RequestBodyInfo(
- required=True,
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- "required": ["name"],
- }
- },
- ),
- )
-
- # Generate schemas
- legacy_schema = legacy_combine_schemas(legacy_route)
- new_schema, param_map = _combine_schemas_and_map_params(new_route)
-
- # Should have path parameter with suffix
- assert "id__path" in legacy_schema["properties"]
- assert "id__path" in new_schema["properties"]
-
- # Should have body parameter without suffix
- assert "id" in legacy_schema["properties"]
- assert "id" in new_schema["properties"]
-
- # Should have name parameter from body
- assert "name" in legacy_schema["properties"]
- assert "name" in new_schema["properties"]
-
- # Required should include path param (suffixed) and required body params
- legacy_required = set(legacy_schema["required"])
- new_required = set(new_schema["required"])
-
- assert "id__path" in legacy_required
- assert "id__path" in new_required
- assert "name" in legacy_required # required in body
- assert "name" in new_required
-
- # Parameter map should correctly map suffixed parameter
- assert param_map["id__path"]["location"] == "path"
- assert param_map["id__path"]["openapi_name"] == "id"
- assert param_map["id"]["location"] == "body"
- assert param_map["name"]["location"] == "body"
-
- @pytest.mark.parametrize(
- "param_type",
- [
- {"type": "integer"},
- {"type": "number"},
- {"type": "boolean"},
- {"type": "array", "items": {"type": "string"}},
- {"type": "object", "properties": {"name": {"type": "string"}}},
- ],
- )
- def test_nullable_behavior_different_types(self, param_type):
- """Test nullable behavior works for all parameter types."""
- # Legacy route
- legacy_route = LegacyHTTPRoute(
- method="GET",
- path="/test",
- parameters=[
- LegacyParameterInfo(
- name="optional_param",
- location="query",
- required=False,
- schema=param_type,
- )
- ],
- request_body=None,
- responses={},
- summary="Test endpoint",
- schema_definitions={},
- )
-
- # New route
- new_route = HTTPRoute(
- method="GET",
- path="/test",
- operation_id="test_op",
- parameters=[
- ParameterInfo(
- name="optional_param",
- location="query",
- required=False,
- schema=param_type,
- )
- ],
- )
-
- # Generate schemas
- legacy_schema = legacy_combine_schemas(legacy_route)
- new_schema, _ = _combine_schemas_and_map_params(new_route)
-
- # Both implementations now correctly preserve original schema
- legacy_param = legacy_schema["properties"]["optional_param"]
- new_param = new_schema["properties"]["optional_param"]
-
- # Both should preserve original schema without making it nullable
- assert "anyOf" not in legacy_param
- assert "anyOf" not in new_param
-
- # Both should match the original parameter schema (plus description in legacy)
- for key, value in param_type.items():
- assert legacy_param[key] == value
- assert new_param[key] == value
-
- def test_no_parameters_no_body(self):
- """Test schema generation when there are no parameters or body."""
- # Legacy route
- legacy_route = LegacyHTTPRoute(
- method="GET",
- path="/health",
- parameters=[],
- request_body=None,
- responses={},
- summary="Health check",
- schema_definitions={},
- )
-
- # New route
- new_route = HTTPRoute(
- method="GET",
- path="/health",
- operation_id="health_check",
- )
-
- # Generate schemas
- legacy_schema = legacy_combine_schemas(legacy_route)
- new_schema, param_map = _combine_schemas_and_map_params(new_route)
-
- # Both should have empty object schemas
- assert legacy_schema["type"] == "object"
- assert new_schema["type"] == "object"
- assert legacy_schema["properties"] == {}
- assert new_schema["properties"] == {}
- assert legacy_schema["required"] == []
- assert new_schema["required"] == []
- assert param_map == {}
-
- def test_body_only_no_parameters(self):
- """Test schema generation with only request body, no parameters."""
- body_schema = {
- "application/json": {
- "type": "object",
- "properties": {
- "title": {"type": "string"},
- "description": {"type": "string"},
- },
- "required": ["title"],
- }
- }
-
- # Legacy route
- legacy_route = LegacyHTTPRoute(
- method="POST",
- path="/items",
- parameters=[],
- request_body=LegacyRequestBodyInfo(
- required=True,
- content_schema=body_schema,
- ),
- responses={},
- summary="Create item",
- schema_definitions={},
- )
-
- # New route
- new_route = HTTPRoute(
- method="POST",
- path="/items",
- operation_id="create_item",
- request_body=RequestBodyInfo(
- required=True,
- content_schema=body_schema,
- ),
- )
-
- # Generate schemas
- legacy_schema = legacy_combine_schemas(legacy_route)
- new_schema, param_map = _combine_schemas_and_map_params(new_route)
-
- # Should have body properties
- assert "title" in legacy_schema["properties"]
- assert "description" in legacy_schema["properties"]
- assert "title" in new_schema["properties"]
- assert "description" in new_schema["properties"]
-
- # Required should match body requirements
- assert "title" in legacy_schema["required"]
- assert "title" in new_schema["required"]
- assert "description" not in legacy_schema["required"]
- assert "description" not in new_schema["required"]
-
- # Parameter map should map body properties
- assert param_map["title"]["location"] == "body"
- assert param_map["description"]["location"] == "body"
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py b/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py
deleted file mode 100644
index 171147104..000000000
--- a/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py
+++ /dev/null
@@ -1,375 +0,0 @@
-"""Tests for nullable field handling in OpenAPI schemas."""
-
-import pytest
-from jsonschema import ValidationError, validate
-
-from fastmcp.experimental.utilities.openapi.json_schema_converter import (
- convert_openapi_schema_to_json_schema,
-)
-
-
-class TestHandleNullableFields:
- """Test conversion of OpenAPI nullable fields to JSON Schema format."""
-
- def test_root_level_nullable_string(self):
- """Test nullable string at root level."""
- input_schema = {"type": "string", "nullable": True}
- expected = {"type": ["string", "null"]}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_root_level_nullable_integer(self):
- """Test nullable integer at root level."""
- input_schema = {"type": "integer", "nullable": True}
- expected = {"type": ["integer", "null"]}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_root_level_nullable_boolean(self):
- """Test nullable boolean at root level."""
- input_schema = {"type": "boolean", "nullable": True}
- expected = {"type": ["boolean", "null"]}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_property_level_nullable_fields(self):
- """Test nullable fields in properties."""
- input_schema = {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "company": {"type": "string", "nullable": True},
- "age": {"type": "integer", "nullable": True},
- "active": {"type": "boolean", "nullable": True},
- },
- }
- expected = {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "company": {"type": ["string", "null"]},
- "age": {"type": ["integer", "null"]},
- "active": {"type": ["boolean", "null"]},
- },
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_mixed_nullable_and_non_nullable(self):
- """Test mix of nullable and non-nullable fields."""
- input_schema = {
- "type": "object",
- "properties": {
- "required_field": {"type": "string"},
- "optional_nullable": {"type": "string", "nullable": True},
- "optional_non_nullable": {"type": "string"},
- },
- "required": ["required_field"],
- }
- expected = {
- "type": "object",
- "properties": {
- "required_field": {"type": "string"},
- "optional_nullable": {"type": ["string", "null"]},
- "optional_non_nullable": {"type": "string"},
- },
- "required": ["required_field"],
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_nullable_false_ignored(self):
- """Test that nullable: false is ignored (removed but no type change)."""
- input_schema = {"type": "string", "nullable": False}
- expected = {"type": "string"}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_no_nullable_field_unchanged(self):
- """Test that schemas without nullable field are unchanged."""
- input_schema = {
- "type": "object",
- "properties": {"name": {"type": "string"}},
- }
- expected = input_schema.copy()
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_nullable_without_type_removes_nullable(self):
- """Test that nullable field is removed even without type."""
- input_schema = {"nullable": True, "description": "Some field"}
- expected = {"description": "Some field"}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_preserves_other_fields(self):
- """Test that other fields are preserved during conversion."""
- input_schema = {
- "type": "string",
- "nullable": True,
- "description": "A nullable string",
- "example": "test",
- "format": "email",
- }
- expected = {
- "type": ["string", "null"],
- "description": "A nullable string",
- "example": "test",
- "format": "email",
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_non_dict_input_unchanged(self):
- """Test that non-dict inputs are returned unchanged."""
- assert convert_openapi_schema_to_json_schema("string", "3.0.0") == "string" # type: ignore[arg-type]
- assert convert_openapi_schema_to_json_schema(123, "3.0.0") == 123 # type: ignore[arg-type]
- assert convert_openapi_schema_to_json_schema(None, "3.0.0") is None # type: ignore[arg-type]
- assert convert_openapi_schema_to_json_schema([1, 2, 3], "3.0.0") == [1, 2, 3] # type: ignore[arg-type]
-
- def test_performance_optimization_no_copy_when_unchanged(self):
- """Test that schemas without nullable fields return the same object (no copy)."""
- input_schema = {
- "type": "object",
- "properties": {"name": {"type": "string"}},
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- # Should return the exact same object, not a copy
- assert result is input_schema
-
- def test_union_types_with_nullable(self):
- """Test nullable handling with existing union types (type as array)."""
- input_schema = {"type": ["string", "integer"], "nullable": True}
- expected = {"type": ["string", "integer", "null"]}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_already_nullable_union_unchanged(self):
- """Test that union types already containing null are not modified."""
- input_schema = {"type": ["string", "null"], "nullable": True}
- expected = {"type": ["string", "null"]}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_property_level_union_with_nullable(self):
- """Test nullable handling with union types in properties."""
- input_schema = {
- "type": "object",
- "properties": {"value": {"type": ["string", "integer"], "nullable": True}},
- }
- expected = {
- "type": "object",
- "properties": {"value": {"type": ["string", "integer", "null"]}},
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_complex_union_nullable_scenarios(self):
- """Test various complex union type scenarios."""
- # Already has null in different position
- input1 = {"type": ["null", "string", "integer"], "nullable": True}
- result1 = convert_openapi_schema_to_json_schema(input1, "3.0.0")
- assert result1 == {"type": ["null", "string", "integer"]}
-
- # Single item array
- input2 = {"type": ["string"], "nullable": True}
- result2 = convert_openapi_schema_to_json_schema(input2, "3.0.0")
- assert result2 == {"type": ["string", "null"]}
-
- def test_oneof_with_nullable(self):
- """Test nullable handling with oneOf constructs."""
- input_schema = {
- "oneOf": [{"type": "string"}, {"type": "integer"}],
- "nullable": True,
- }
- expected = {
- "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_anyof_with_nullable(self):
- """Test nullable handling with anyOf constructs."""
- input_schema = {
- "anyOf": [{"type": "string"}, {"type": "integer"}],
- "nullable": True,
- }
- expected = {
- "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_anyof_already_nullable(self):
- """Test anyOf that already contains null type."""
- input_schema = {
- "anyOf": [{"type": "string"}, {"type": "null"}],
- "nullable": True,
- }
- expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_allof_with_nullable(self):
- """Test nullable handling with allOf constructs."""
- input_schema = {
- "allOf": [{"type": "string"}, {"minLength": 1}],
- "nullable": True,
- }
- expected = {
- "anyOf": [
- {"allOf": [{"type": "string"}, {"minLength": 1}]},
- {"type": "null"},
- ]
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_property_level_oneof_with_nullable(self):
- """Test nullable handling with oneOf in properties."""
- input_schema = {
- "type": "object",
- "properties": {
- "value": {
- "oneOf": [{"type": "string"}, {"type": "integer"}],
- "nullable": True,
- }
- },
- }
- expected = {
- "type": "object",
- "properties": {
- "value": {
- "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
- }
- },
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_nullable_enum_field(self):
- """Test nullable enum field - issue #2082."""
- input_schema = {
- "type": "string",
- "nullable": True,
- "enum": ["VALUE1", "VALUE2", "VALUE3"],
- }
- expected = {
- "type": ["string", "null"],
- "enum": ["VALUE1", "VALUE2", "VALUE3", None],
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_nullable_enum_already_contains_null(self):
- """Test nullable enum that already contains None."""
- input_schema = {
- "type": "string",
- "nullable": True,
- "enum": ["VALUE1", "VALUE2", None],
- }
- expected = {
- "type": ["string", "null"],
- "enum": ["VALUE1", "VALUE2", None],
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_nullable_enum_without_type(self):
- """Test nullable enum without explicit type field."""
- input_schema = {
- "nullable": True,
- "enum": ["VALUE1", "VALUE2", "VALUE3"],
- }
- expected = {
- "enum": ["VALUE1", "VALUE2", "VALUE3", None],
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_non_nullable_enum_unchanged(self):
- """Test that enum without nullable is unchanged."""
- input_schema = {
- "type": "string",
- "enum": ["VALUE1", "VALUE2", "VALUE3"],
- }
- expected = {
- "type": "string",
- "enum": ["VALUE1", "VALUE2", "VALUE3"],
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_property_level_nullable_enum(self):
- """Test nullable enum in object properties."""
- input_schema = {
- "type": "object",
- "properties": {
- "status": {
- "type": "string",
- "nullable": True,
- "enum": ["ACTIVE", "INACTIVE", "PENDING"],
- },
- "name": {"type": "string"},
- },
- }
- expected = {
- "type": "object",
- "properties": {
- "status": {
- "type": ["string", "null"],
- "enum": ["ACTIVE", "INACTIVE", "PENDING", None],
- },
- "name": {"type": "string"},
- },
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
- def test_nullable_integer_enum(self):
- """Test nullable enum with integer values."""
- input_schema = {
- "type": "integer",
- "nullable": True,
- "enum": [1, 2, 3],
- }
- expected = {
- "type": ["integer", "null"],
- "enum": [1, 2, 3, None],
- }
- result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
- assert result == expected
-
-
-class TestNullableFieldValidation:
- """Test that converted schemas validate correctly with jsonschema."""
-
- def test_nullable_string_validates(self):
- """Test that nullable string validates both null and string values."""
- openapi_schema = {"type": "string", "nullable": True}
- json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0")
-
- # Both null and string should validate
- validate(instance=None, schema=json_schema)
- validate(instance="test", schema=json_schema)
-
- # Other types should fail
- with pytest.raises(ValidationError):
- validate(instance=123, schema=json_schema)
-
- def test_nullable_enum_validates(self):
- """Test that nullable enum validates null, enum values, and rejects invalid values."""
- openapi_schema = {
- "type": "string",
- "nullable": True,
- "enum": ["VALUE1", "VALUE2", "VALUE3"],
- }
- json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0")
-
- # Null and enum values should validate
- validate(instance=None, schema=json_schema)
- validate(instance="VALUE1", schema=json_schema)
-
- # Invalid values should fail
- with pytest.raises(ValidationError):
- validate(instance="INVALID", schema=json_schema)
diff --git a/tests/server/openapi/__init__.py b/tests/server/openapi/__init__.py
index e69de29bb..b862f61a3 100644
--- a/tests/server/openapi/__init__.py
+++ b/tests/server/openapi/__init__.py
@@ -0,0 +1 @@
+"""Tests for openapi_new server components."""
diff --git a/tests/server/openapi/conftest.py b/tests/server/openapi/conftest.py
deleted file mode 100644
index 9a7b58f4d..000000000
--- a/tests/server/openapi/conftest.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import httpx
-import pytest
-from fastapi import FastAPI, HTTPException, Response
-from fastapi.responses import PlainTextResponse
-from httpx import ASGITransport, AsyncClient
-from pydantic import BaseModel
-
-from fastmcp.server.openapi import (
- FastMCPOpenAPI,
- MCPType,
- RouteMap,
-)
-
-
-class User(BaseModel):
- id: int
- name: str
- active: bool
-
-
-class UserCreate(BaseModel):
- name: str
- active: bool
-
-
-@pytest.fixture
-def users_db() -> dict[int, User]:
- return {
- 1: User(id=1, name="Alice", active=True),
- 2: User(id=2, name="Bob", active=True),
- 3: User(id=3, name="Charlie", active=False),
- }
-
-
-# route maps for GET requests
-# use these to create components of all types instead of just tools
-GET_ROUTE_MAPS = [
- # GET requests with path parameters go to ResourceTemplate
- RouteMap(
- methods=["GET"],
- pattern=r".*\{.*\}.*",
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- ),
- # GET requests without path parameters go to Resource
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
-]
-
-
-@pytest.fixture
-def fastapi_app(users_db: dict[int, User]) -> FastAPI:
- app = FastAPI(title="FastAPI App")
-
- @app.get("/users", tags=["users", "list"])
- async def get_users() -> list[User]:
- """Get all users."""
- return sorted(users_db.values(), key=lambda x: x.id)
-
- @app.get("/search", tags=["search"])
- async def search_users(
- name: str | None = None, active: bool | None = None, min_id: int | None = None
- ) -> list[User]:
- """Search users with optional filters."""
- results = list(users_db.values())
-
- if name is not None:
- results = [u for u in results if name.lower() in u.name.lower()]
- if active is not None:
- results = [u for u in results if u.active == active]
- if min_id is not None:
- results = [u for u in results if u.id >= min_id]
-
- return sorted(results, key=lambda x: x.id)
-
- @app.get("/users/{user_id}", tags=["users", "detail"])
- async def get_user(user_id: int) -> User | None:
- """Get a user by ID."""
- return users_db.get(user_id)
-
- @app.get("/users/{user_id}/{is_active}", tags=["users", "detail"])
- async def get_user_active_state(user_id: int, is_active: bool) -> User | None:
- """Get a user by ID and filter by active state."""
- user = users_db.get(user_id)
- if user is not None and user.active == is_active:
- return user
- return None
-
- @app.post("/users", tags=["users", "create"])
- async def create_user(user: UserCreate) -> User:
- """Create a new user."""
- user_id = max(users_db.keys()) + 1
- new_user = User(id=user_id, name=user.name, active=user.active)
- users_db[user_id] = new_user
- return new_user
-
- @app.patch("/users/{user_id}/name", tags=["users", "update"])
- async def update_user_name(user_id: int, name: str) -> User:
- """Update a user's name."""
- user = users_db.get(user_id)
- if user is None:
- raise HTTPException(status_code=404, detail="User not found")
- user.name = name
- return user
-
- @app.get("/ping", response_class=PlainTextResponse)
- async def ping() -> str:
- """Ping the server."""
- return "pong"
-
- @app.get("/ping-bytes")
- async def ping_bytes() -> Response:
- """Ping the server and get a bytes response."""
-
- return Response(content=b"pong")
-
- return app
-
-
-@pytest.fixture
-def api_client(fastapi_app: FastAPI) -> AsyncClient:
- """Create a pre-configured httpx client for testing."""
- return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
-
-
-@pytest.fixture
-async def fastmcp_openapi_server(
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
-) -> FastMCPOpenAPI:
- openapi_spec = fastapi_app.openapi()
-
- return FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=api_client,
- name="Test App",
- route_maps=GET_ROUTE_MAPS,
- )
diff --git a/tests/server/openapi/test_advanced_behavior.py b/tests/server/openapi/test_advanced_behavior.py
deleted file mode 100644
index 159c47900..000000000
--- a/tests/server/openapi/test_advanced_behavior.py
+++ /dev/null
@@ -1,315 +0,0 @@
-from enum import Enum
-from urllib.parse import parse_qs, urlparse
-
-import httpx
-import pytest
-from fastapi import FastAPI
-from httpx import ASGITransport, AsyncClient
-
-from fastmcp.client import Client
-from fastmcp.exceptions import ToolError
-from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
-
-
-async def test_empty_query_parameters_not_sent(
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
-):
- """Test that empty and None query parameters are not sent in the request."""
-
- # Create a TransportAdapter to track requests
- class RequestCapture(httpx.AsyncBaseTransport):
- def __init__(self, wrapped):
- self.wrapped = wrapped
- self.requests = []
-
- async def handle_async_request(self, request):
- self.requests.append(request)
- return await self.wrapped.handle_async_request(request)
-
- # Use our transport adapter to wrap the original one
- capture = RequestCapture(api_client._transport)
- api_client._transport = capture
-
- # Create the OpenAPI server with new route map to make search endpoint a tool
- openapi_spec = fastapi_app.openapi()
- mcp_server = FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=api_client,
- route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
- )
-
- # Call the search tool with mixed parameter values
- async with Client(mcp_server) as client:
- await client.call_tool(
- "search_users_search_get",
- {
- "name": "", # Empty string should be excluded
- "active": None, # None should be excluded
- "min_id": 2, # Has value, should be included
- },
- )
-
- # Verify that the request URL only has min_id parameter
- assert len(capture.requests) > 0
- request = capture.requests[-1] # Get the last request
-
- # URL should only contain min_id=2, not name= or active=
- url = str(request.url)
- assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}"
- assert "name=" not in url, f"URL should not contain name=, got: {url}"
- assert "active=" not in url, f"URL should not contain active=, got: {url}"
-
- # More direct check - parse the URL to examine query params
- parsed_url = urlparse(url)
- query_params = parse_qs(parsed_url.query)
-
- assert "min_id" in query_params
- assert "name" not in query_params
- assert "active" not in query_params
-
-
-async def test_none_path_parameters_rejected(
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
-):
- """Test that None values for path parameters are properly rejected."""
- # Create the OpenAPI server
- openapi_spec = fastapi_app.openapi()
- mcp_server = FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=api_client,
- )
-
- # Create a client and try to call a tool with a None path parameter
- async with Client(mcp_server) as client:
- # get_user has a required path parameter user_id
- with pytest.raises(
- ToolError, match="Input validation error|Missing required path parameters"
- ):
- await client.call_tool(
- "update_user_name_users",
- {
- "user_id": None, # This should cause an error
- "name": "New Name",
- },
- )
-
-
-class TestTagTransfer:
- """Tests for transferring tags from OpenAPI routes to MCP objects."""
-
- async def test_tags_transferred_to_tools(
- self, fastmcp_openapi_server: FastMCPOpenAPI
- ):
- """Test that tags from OpenAPI routes are correctly transferred to Tools."""
- # Get internal tools directly (not the public API which returns MCP.Content)
- tools_dict = await fastmcp_openapi_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
-
- # Find the create_user and update_user_name tools
- create_user_tool = next(
- (t for t in tools if t.name == "create_user_users_post"), None
- )
- update_user_tool = next(
- (t for t in tools if t.name == "update_user_name_users"),
- None,
- )
-
- assert create_user_tool is not None
- assert update_user_tool is not None
-
- # Check that tags from OpenAPI routes were transferred to the Tool objects
- assert "users" in create_user_tool.tags
- assert "create" in create_user_tool.tags
- assert len(create_user_tool.tags) == 2
-
- assert "users" in update_user_tool.tags
- assert "update" in update_user_tool.tags
- assert len(update_user_tool.tags) == 2
-
- async def test_tags_transferred_to_resources(
- self, fastmcp_openapi_server: FastMCPOpenAPI
- ):
- """Test that tags from OpenAPI routes are correctly transferred to Resources."""
- # Get internal resources directly
- resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
- resources = list(resources_dict.values())
-
- # Find the get_users resource
- get_users_resource = next(
- (r for r in resources if r.name == "get_users_users_get"), None
- )
-
- assert get_users_resource is not None
-
- # Check that tags from OpenAPI routes were transferred to the Resource object
- assert "users" in get_users_resource.tags
- assert "list" in get_users_resource.tags
- assert len(get_users_resource.tags) == 2
-
- async def test_tags_transferred_to_resource_templates(
- self, fastmcp_openapi_server: FastMCPOpenAPI
- ):
- """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
- # Get internal resource templates directly
- templates_dict = (
- await fastmcp_openapi_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
-
- # Find the get_user template
- get_user_template = next(
- (t for t in templates if t.name == "get_user_users"), None
- )
-
- assert get_user_template is not None
-
- # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object
- assert "users" in get_user_template.tags
- assert "detail" in get_user_template.tags
- assert len(get_user_template.tags) == 2
-
- async def test_tags_preserved_in_resources_created_from_templates(
- self, fastmcp_openapi_server: FastMCPOpenAPI
- ):
- """Test that tags are preserved when creating resources from templates."""
- # Get internal resource templates directly
- templates_dict = (
- await fastmcp_openapi_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
-
- # Find the get_user template
- get_user_template = next(
- (t for t in templates if t.name == "get_user_users"), None
- )
-
- assert get_user_template is not None
-
- # Manually create a resource from template
- params = {"user_id": 1}
- resource = await get_user_template.create_resource(
- "resource://get_user_users/1", params
- )
-
- # Verify tags are preserved from template to resource
- assert "users" in resource.tags
- assert "detail" in resource.tags
- assert len(resource.tags) == 2
-
-
-class TestReprMethods:
- """Tests for the custom __repr__ methods of OpenAPI objects."""
-
- async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
- """Test that OpenAPITool's __repr__ method works without recursion errors."""
- tools_dict = await fastmcp_openapi_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- tool = next(iter(tools))
-
- # Verify repr doesn't cause recursion and contains expected elements
- tool_repr = repr(tool)
- assert "OpenAPITool" in tool_repr
- assert f"name={tool.name!r}" in tool_repr
- assert "method=" in tool_repr
- assert "path=" in tool_repr
-
- async def test_openapi_resource_repr(self, fastmcp_openapi_server: FastMCPOpenAPI):
- """Test that OpenAPIResource's __repr__ method works without recursion errors."""
- resources_dict = await fastmcp_openapi_server._resource_manager.get_resources()
- resources = list(resources_dict.values())
- resource = next(iter(resources))
-
- # Verify repr doesn't cause recursion and contains expected elements
- resource_repr = repr(resource)
- assert "OpenAPIResource" in resource_repr
- assert f"name={resource.name!r}" in resource_repr
- assert "uri=" in resource_repr
- assert "path=" in resource_repr
-
- async def test_openapi_resource_template_repr(
- self, fastmcp_openapi_server: FastMCPOpenAPI
- ):
- """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
- templates_dict = (
- await fastmcp_openapi_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
- template = next(iter(templates))
-
- # Verify repr doesn't cause recursion and contains expected elements
- template_repr = repr(template)
- assert "OpenAPIResourceTemplate" in template_repr
- assert f"name={template.name!r}" in template_repr
- assert "uri_template=" in template_repr
- assert "path=" in template_repr
-
-
-class TestEnumHandling:
- """Tests for handling enum parameters in OpenAPI schemas."""
-
- async def test_enum_parameter_schema(self):
- """Test that enum parameters are properly handled in tool parameter schemas."""
-
- # Define an enum just like in example.py
- class QueryEnum(str, Enum):
- foo = "foo"
- bar = "bar"
- baz = "baz"
-
- # Create a minimal FastAPI app with an endpoint using the enum
- app = FastAPI()
-
- @app.post("/items/{item_id}")
- def read_item(
- item_id: int,
- query: QueryEnum | None = None,
- ):
- return {"item_id": item_id, "query": query}
-
- # Create a client for the app
- client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
-
- # Create the FastMCPOpenAPI server from the app
- openapi_spec = app.openapi()
- server = FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=client,
- name="Enum Test",
- )
-
- # Get the tools from the server
- tools_dict = await server._tool_manager.get_tools()
- tools = list(tools_dict.values())
-
- # Find the read_item tool
- read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
-
- # Verify the tool exists
- assert read_item_tool is not None, "read_item tool wasn't created"
-
- # Check that the parameters include the enum reference
- assert "properties" in read_item_tool.parameters
- assert "query" in read_item_tool.parameters["properties"]
-
- # Check for the anyOf with $ref to the enum definition
- query_param = read_item_tool.parameters["properties"]["query"]
- assert "anyOf" in query_param
-
- # Find the ref in the anyOf list
- ref_found = False
- for option in query_param["anyOf"]:
- if "$ref" in option and option["$ref"].startswith("#/$defs/QueryEnum"):
- ref_found = True
- break
-
- assert ref_found, "Reference to enum definition not found in query parameter"
-
- # Check that the $defs section exists and contains the enum definition
- assert "$defs" in read_item_tool.parameters
- assert "QueryEnum" in read_item_tool.parameters["$defs"]
-
- # Verify the enum definition
- enum_def = read_item_tool.parameters["$defs"]["QueryEnum"]
- assert "enum" in enum_def
- assert enum_def["enum"] == ["foo", "bar", "baz"]
- assert enum_def["type"] == "string"
diff --git a/tests/server/openapi/test_basic_functionality.py b/tests/server/openapi/test_basic_functionality.py
deleted file mode 100644
index f92031098..000000000
--- a/tests/server/openapi/test_basic_functionality.py
+++ /dev/null
@@ -1,369 +0,0 @@
-import base64
-import json
-import re
-
-import httpx
-from dirty_equals import IsStr
-from fastapi import FastAPI
-from mcp.types import BlobResourceContents
-from pydantic import TypeAdapter
-from pydantic.networks import AnyUrl
-
-from fastmcp import FastMCP
-from fastmcp.client import Client
-from fastmcp.server.openapi import (
- FastMCPOpenAPI,
- MCPType,
- OpenAPIResource,
- OpenAPIResourceTemplate,
- OpenAPITool,
- RouteMap,
-)
-
-from .conftest import GET_ROUTE_MAPS, User
-
-
-async def test_create_openapi_server(
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
-):
- openapi_spec = fastapi_app.openapi()
-
- server = FastMCPOpenAPI(
- openapi_spec=openapi_spec, client=api_client, name="Test App"
- )
-
- assert isinstance(server, FastMCP)
- assert server.name == "Test App"
-
-
-async def test_create_openapi_server_classmethod(
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
-):
- server = FastMCP.from_openapi(openapi_spec=fastapi_app.openapi(), client=api_client)
- assert isinstance(server, FastMCPOpenAPI)
- assert server.name == "OpenAPI FastMCP"
-
-
-async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
- server = FastMCP.from_fastapi(fastapi_app)
- assert isinstance(server, FastMCPOpenAPI)
- assert server.name == "FastAPI App"
-
-
-async def test_create_openapi_server_with_timeout(
- fastapi_app: FastAPI, api_client: httpx.AsyncClient
-):
- server = FastMCPOpenAPI(
- openapi_spec=fastapi_app.openapi(),
- client=api_client,
- name="Test App",
- timeout=1.0,
- route_maps=GET_ROUTE_MAPS,
- )
- assert server._timeout == 1.0
-
- for tool in (await server.get_tools()).values():
- assert isinstance(tool, OpenAPITool)
- assert tool._timeout == 1.0
-
- for resource in (await server.get_resources()).values():
- assert isinstance(resource, OpenAPIResource)
- assert resource._timeout == 1.0
-
- for template in (await server.get_resource_templates()).values():
- assert isinstance(template, OpenAPIResourceTemplate)
- assert template._timeout == 1.0
-
-
-class TestTools:
- async def test_default_behavior_converts_everything_to_tools(
- self, fastapi_app: FastAPI
- ):
- """
- By default, tools exclude GET methods
- """
- server = FastMCPOpenAPI.from_fastapi(fastapi_app)
- assert len(await server.get_tools()) == 8
- assert len(await server.get_resources()) == 0
- assert len(await server.get_resource_templates()) == 0
-
- async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
- """
- By default, tools exclude GET methods
- """
- async with Client(fastmcp_openapi_server) as client:
- tools = await client.list_tools()
- assert len(tools) == 2
-
- assert tools[0].model_dump() == dict(
- name="create_user_users_post",
- meta=dict(_fastmcp=dict(tags=["create", "users"])),
- title=None,
- annotations=None,
- icons=None,
- description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL),
- inputSchema={
- "type": "object",
- "properties": {
- "name": {"type": "string", "title": "Name"},
- "active": {"type": "boolean", "title": "Active"},
- },
- "required": ["name", "active"],
- },
- outputSchema={
- "type": "object",
- "properties": {
- "id": {"type": "integer", "title": "Id"},
- "name": {"type": "string", "title": "Name"},
- "active": {"type": "boolean", "title": "Active"},
- },
- "required": ["id", "name", "active"],
- "title": "User",
- },
- )
- assert tools[1].model_dump() == dict(
- name="update_user_name_users",
- meta=dict(_fastmcp=dict(tags=["update", "users"])),
- title=None,
- annotations=None,
- icons=None,
- description=IsStr(
- regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "user_id": {"type": "integer", "title": "User Id"},
- "name": {"type": "string", "title": "Name"},
- },
- "required": ["user_id", "name"],
- },
- outputSchema={
- "type": "object",
- "properties": {
- "id": {"type": "integer", "title": "Id"},
- "name": {"type": "string", "title": "Name"},
- "active": {"type": "boolean", "title": "Active"},
- },
- "required": ["id", "name", "active"],
- "title": "User",
- },
- )
-
- async def test_call_create_user_tool(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- ):
- """
- The tool created by the OpenAPI server should be the same as the original
- """
- async with Client(fastmcp_openapi_server) as client:
- tool_response = await client.call_tool(
- "create_user_users_post", {"name": "David", "active": False}
- )
-
- expected_user = User(id=4, name="David", active=False)
- # Compare the data content since MCP client creates different class instances
- assert tool_response.data.id == expected_user.id
- assert tool_response.data.name == expected_user.name
- assert tool_response.data.active == expected_user.active
-
- # Check that the user was created via API
- response = await api_client.get("/users")
- assert len(response.json()) == 4
-
- # Check that the user was created via MCP
- async with Client(fastmcp_openapi_server) as client:
- user_response = await client.read_resource("resource://get_user_users/4")
- response_text = user_response[0].text # type: ignore[attr-defined]
- user = json.loads(response_text)
- assert user == expected_user.model_dump()
-
- async def test_call_update_user_name_tool(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- ):
- """
- The tool created by the OpenAPI server should be the same as the original
- """
- async with Client(fastmcp_openapi_server) as client:
- tool_response = await client.call_tool(
- "update_user_name_users",
- {"user_id": 1, "name": "XYZ"},
- )
-
- expected_user = User(id=1, name="XYZ", active=True)
- # Compare the data content since MCP client creates different class instances
- assert tool_response.data.id == expected_user.id
- assert tool_response.data.name == expected_user.name
- assert tool_response.data.active == expected_user.active
-
- # Check that the user was updated via API
- response = await api_client.get("/users")
- assert expected_user.model_dump() in response.json()
-
- # Check that the user was updated via MCP
- async with Client(fastmcp_openapi_server) as client:
- user_response = await client.read_resource("resource://get_user_users/1")
- response_text = user_response[0].text # type: ignore[attr-defined]
- user = json.loads(response_text)
- assert user == expected_user.model_dump()
-
- async def test_call_tool_return_list(
- self,
- fastapi_app: FastAPI,
- api_client: httpx.AsyncClient,
- users_db: dict[int, User],
- ):
- """
- The tool created by the OpenAPI server should return a list of content.
- """
- openapi_spec = fastapi_app.openapi()
- mcp_server = FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=api_client,
- route_maps=[
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
- ],
- )
- async with Client(mcp_server) as client:
- tool_response = await client.call_tool("get_users_users_get", {})
- # The tool response should now be unwrapped since we have output schema
- assert tool_response.data == [
- user.model_dump()
- for user in sorted(users_db.values(), key=lambda x: x.id)
- ]
-
-
-class TestResources:
- async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
- """
- By default, resources exclude GET methods without parameters
- """
- async with Client(fastmcp_openapi_server) as client:
- resources = await client.list_resources()
- assert len(resources) == 4
- assert resources[0].uri == AnyUrl("resource://get_users_users_get")
- assert resources[0].name == "get_users_users_get"
-
- async def test_get_resource(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- users_db: dict[int, User],
- ):
- """
- The resource created by the OpenAPI server should be the same as the original
- """
-
- json_users = TypeAdapter(list[User]).dump_python(
- sorted(users_db.values(), key=lambda x: x.id)
- )
- async with Client(fastmcp_openapi_server) as client:
- resource_response = await client.read_resource(
- "resource://get_users_users_get"
- )
- response_text = resource_response[0].text # type: ignore[attr-defined]
- resource = json.loads(response_text)
- assert resource == json_users
- response = await api_client.get("/users")
- assert response.json() == json_users
-
- async def test_get_bytes_resource(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- ):
- """Test reading a resource that returns bytes."""
- async with Client(fastmcp_openapi_server) as client:
- resource_response = await client.read_resource(
- "resource://ping_bytes_ping_bytes_get"
- )
- assert isinstance(resource_response[0], BlobResourceContents)
- assert base64.b64decode(resource_response[0].blob) == b"pong"
-
- async def test_get_str_resource(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- ):
- """Test reading a resource that returns a string."""
- async with Client(fastmcp_openapi_server) as client:
- resource_response = await client.read_resource("resource://ping_ping_get")
- assert resource_response[0].text == "pong" # type: ignore[attr-defined]
-
-
-class TestResourceTemplates:
- async def test_list_resource_templates(
- self, fastmcp_openapi_server: FastMCPOpenAPI
- ):
- """
- By default, resource templates exclude GET methods without parameters
- """
- async with Client(fastmcp_openapi_server) as client:
- resource_templates = await client.list_resource_templates()
- assert len(resource_templates) == 2
- assert resource_templates[0].name == "get_user_users"
- assert (
- resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}"
- )
- assert resource_templates[1].name == "get_user_active_state_users"
- assert (
- resource_templates[1].uriTemplate
- == r"resource://get_user_active_state_users/{is_active}/{user_id}"
- )
-
- async def test_get_resource_template(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- users_db: dict[int, User],
- ):
- """
- The resource template created by the OpenAPI server should be the same as the original
- """
- user_id = 2
- async with Client(fastmcp_openapi_server) as client:
- resource_response = await client.read_resource(
- f"resource://get_user_users/{user_id}"
- )
- response_text = resource_response[0].text # type: ignore[attr-defined]
- resource = json.loads(response_text)
-
- assert resource == users_db[user_id].model_dump()
- response = await api_client.get(f"/users/{user_id}")
- assert resource == response.json()
-
- async def test_get_resource_template_multi_param(
- self,
- fastmcp_openapi_server: FastMCPOpenAPI,
- api_client,
- users_db: dict[int, User],
- ):
- """
- The resource template created by the OpenAPI server should be the same as the original
- """
- user_id = 2
- is_active = True
- async with Client(fastmcp_openapi_server) as client:
- resource_response = await client.read_resource(
- f"resource://get_user_active_state_users/{is_active}/{user_id}"
- )
- response_text = resource_response[0].text # type: ignore[attr-defined]
- resource = json.loads(response_text)
-
- assert resource == users_db[user_id].model_dump()
- response = await api_client.get(f"/users/{user_id}/{is_active}")
- assert resource == response.json()
-
-
-class TestPrompts:
- async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
- """
- By default, there are no prompts.
- """
- async with Client(fastmcp_openapi_server) as client:
- prompts = await client.list_prompts()
- assert len(prompts) == 0
diff --git a/tests/experimental/openapi_parser/server/openapi/test_comprehensive.py b/tests/server/openapi/test_comprehensive.py
similarity index 99%
rename from tests/experimental/openapi_parser/server/openapi/test_comprehensive.py
rename to tests/server/openapi/test_comprehensive.py
index 2473914e6..e7602c06c 100644
--- a/tests/experimental/openapi_parser/server/openapi/test_comprehensive.py
+++ b/tests/server/openapi/test_comprehensive.py
@@ -8,7 +8,7 @@ import pytest
from httpx import Response
from fastmcp.client import Client
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
+from fastmcp.server.openapi import FastMCPOpenAPI
class TestOpenAPIComprehensive:
diff --git a/tests/server/openapi/test_configuration.py b/tests/server/openapi/test_configuration.py
deleted file mode 100644
index 38e2c6f05..000000000
--- a/tests/server/openapi/test_configuration.py
+++ /dev/null
@@ -1,933 +0,0 @@
-import httpx
-import pytest
-from fastapi import FastAPI
-
-from fastmcp import FastMCP
-from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
-
-from .conftest import GET_ROUTE_MAPS
-
-
-class TestRouteMapWildcard:
- """Tests for wildcard RouteMap methods functionality."""
-
- @pytest.fixture
- def basic_openapi_spec(self) -> dict:
- """Create a minimal OpenAPI spec with different HTTP methods."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "get": {
- "operationId": "getUsers",
- "responses": {"200": {"description": "Success"}},
- },
- "post": {
- "operationId": "createUser",
- "responses": {"201": {"description": "Created"}},
- },
- },
- "/posts": {
- "get": {
- "operationId": "getPosts",
- "responses": {"200": {"description": "Success"}},
- },
- "post": {
- "operationId": "createPost",
- "responses": {"201": {"description": "Created"}},
- },
- },
- },
- }
-
- @pytest.fixture
- async def mock_basic_client(self) -> httpx.AsyncClient:
- """Create a simple mock client."""
-
- async def _responder(request):
- return httpx.Response(200, json={"status": "ok"})
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- async def test_wildcard_matches_all_methods(
- self, basic_openapi_spec, mock_basic_client
- ):
- """Test that a RouteMap with methods='*' matches all HTTP methods."""
- # Create a single route map with wildcard method
- route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
-
- mcp = FastMCPOpenAPI(
- openapi_spec=basic_openapi_spec,
- client=mock_basic_client,
- route_maps=route_maps,
- )
-
- # All operations should be mapped to tools
- tools_dict = await mcp._tool_manager.get_tools()
- tool_names = {tool.name for tool in tools_dict.values()}
-
- # Check that all 4 operations became tools
- expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
- assert tool_names == expected_tools
-
-
-class TestRouteMapTags:
- """Tests for RouteMap tags functionality."""
-
- @pytest.fixture
- def tagged_openapi_spec(self) -> dict:
- """Create an OpenAPI spec with various tags for testing."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Tagged API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "get": {
- "operationId": "getUsers",
- "tags": ["users", "public"],
- "responses": {"200": {"description": "Success"}},
- },
- "post": {
- "operationId": "createUser",
- "tags": ["users", "admin"],
- "responses": {"201": {"description": "Created"}},
- },
- },
- "/admin/stats": {
- "get": {
- "operationId": "getAdminStats",
- "tags": ["admin", "internal"],
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/health": {
- "get": {
- "operationId": "getHealth",
- "tags": ["public"],
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/metrics": {
- "get": {
- "operationId": "getMetrics",
- "responses": {"200": {"description": "Success"}},
- }
- },
- },
- }
-
- @pytest.fixture
- async def mock_client(self) -> httpx.AsyncClient:
- """Create a simple mock client."""
-
- async def _responder(request):
- return httpx.Response(200, json={"status": "ok"})
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- async def test_tags_as_tools(self, tagged_openapi_spec, mock_client):
- """Test that routes with specific tags are converted to tools."""
- # Convert routes with "admin" tag to tools
- route_maps = [
- RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=tagged_openapi_spec,
- client=mock_client,
- route_maps=route_maps,
- )
-
- # Check that admin-tagged routes are tools
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {t.name for t in tools_dict.values()}
-
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {r.name for r in resources_dict.values()}
-
- # Routes with "admin" tag should be tools
- assert "createUser" in tool_names
- assert "getAdminStats" in tool_names
-
- # Routes without "admin" tag should be resources
- assert "getUsers" in resource_names
- assert "getHealth" in resource_names
- assert "getMetrics" in resource_names
-
- async def test_exclude_tags(self, tagged_openapi_spec, mock_client):
- """Test that routes with specific tags are excluded."""
- # Exclude routes with "internal" tag
- route_maps = [
- RouteMap(
- methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
- ),
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=tagged_openapi_spec,
- client=mock_client,
- route_maps=route_maps,
- )
-
- # Check that internal-tagged routes are excluded
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {r.name for r in resources_dict.values()}
-
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {t.name for t in tools_dict.values()}
-
- # Internal-tagged route should be excluded
- assert "getAdminStats" not in resource_names
- assert "getAdminStats" not in tool_names
-
- # Other routes should still be present
- assert "getUsers" in resource_names
- assert "getHealth" in resource_names
- assert "getMetrics" in resource_names
- assert "createUser" in tool_names
-
- async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client):
- """Test that routes must have ALL specified tags (AND condition)."""
- # Routes must have BOTH "users" AND "admin" tags
- route_maps = [
- RouteMap(
- methods="*",
- pattern=r".*",
- mcp_type=MCPType.TOOL,
- tags={"users", "admin"},
- ),
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=tagged_openapi_spec,
- client=mock_client,
- route_maps=route_maps,
- )
-
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {t.name for t in tools_dict.values()}
-
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {r.name for r in resources_dict.values()}
-
- # Only createUser has both "users" AND "admin" tags
- assert "createUser" in tool_names
-
- # Other routes should be resources
- assert "getUsers" in resource_names # has "users" but not "admin"
- assert "getAdminStats" in resource_names # has "admin" but not "users"
- assert "getHealth" in resource_names
- assert "getMetrics" in resource_names
-
- async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client):
- """Test that both pattern and tags must be satisfied."""
- # Routes matching pattern AND having specific tags
- route_maps = [
- RouteMap(
- methods="*",
- pattern=r".*/admin/.*",
- mcp_type=MCPType.TOOL,
- tags={"admin"},
- ),
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=tagged_openapi_spec,
- client=mock_client,
- route_maps=route_maps,
- )
-
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {t.name for t in tools_dict.values()}
-
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {r.name for r in resources_dict.values()}
-
- # Only getAdminStats matches both /admin/ pattern AND "admin" tag
- assert "getAdminStats" in tool_names
-
- # createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule
- assert "createUser" in tool_names
-
- # Other routes should be resources (GET)
- assert "getUsers" in resource_names
- assert "getHealth" in resource_names
- assert "getMetrics" in resource_names
-
- async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client):
- """Test that empty tags set is ignored (matches all routes)."""
- # Empty tags should match all routes
- route_maps = [
- RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=tagged_openapi_spec,
- client=mock_client,
- route_maps=route_maps,
- )
-
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {t.name for t in tools_dict.values()}
-
- # All routes should be tools since empty tags matches everything
- expected_tools = {
- "getUsers",
- "createUser",
- "getAdminStats",
- "getHealth",
- "getMetrics",
- }
- assert tool_names == expected_tools
-
-
-class TestMCPNames:
- """Tests for the mcp_names dictionary functionality."""
-
- @pytest.fixture
- def mcp_names_openapi_spec(self) -> dict:
- """OpenAPI spec with various operationIds for testing naming strategies."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "MCP Names Test API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "get": {
- "operationId": "list_users__with_pagination",
- "summary": "Get All Users",
- "responses": {"200": {"description": "Success"}},
- },
- "post": {
- "operationId": "create_user_admin__special_permissions",
- "summary": "Create New User",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {"name": {"type": "string"}},
- "required": ["name"],
- }
- }
- },
- },
- "responses": {"201": {"description": "Created"}},
- },
- },
- "/users/{id}": {
- "get": {
- "operationId": "get_user_by_id__admin_only",
- "summary": "Fetch Single User Profile",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "integer"},
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/very-long-endpoint-name": {
- "get": {
- "operationId": "this_is_a_very_long_operation_id_that_exceeds_fifty_six_characters_and_should_be_truncated",
- "summary": "This Is A Very Long Summary That Should Also Be Truncated When Used As Name",
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/special": {
- "get": {
- "operationId": "special-chars@and#spaces in$operation%id",
- "summary": "Special Chars & Spaces In Summary!",
- "responses": {"200": {"description": "Success"}},
- }
- },
- },
- }
-
- @pytest.fixture
- async def mock_client(self) -> httpx.AsyncClient:
- """Mock client for testing."""
-
- async def _responder(request):
- return httpx.Response(200, json={"status": "ok"})
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- async def test_mcp_names_custom_mapping(self, mcp_names_openapi_spec, mock_client):
- """Test that mcp_names dictionary provides custom names for components."""
- mcp_names = {
- "list_users__with_pagination": "user_list",
- "create_user_admin__special_permissions": "admin_create_user",
- "get_user_by_id__admin_only": "user_detail",
- }
-
- server = FastMCPOpenAPI(
- openapi_spec=mcp_names_openapi_spec,
- client=mock_client,
- mcp_names=mcp_names,
- route_maps=GET_ROUTE_MAPS,
- )
-
- # Check tools use custom names
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {tool.name for tool in tools_dict.values()}
- assert "admin_create_user" in tool_names
-
- # Check resource templates use custom names
- templates_dict = await server._resource_manager.get_resource_templates()
- template_names = {template.name for template in templates_dict.values()}
- assert "user_detail" in template_names
-
- # Check resources use custom names
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {resource.name for resource in resources_dict.values()}
- assert "user_list" in resource_names
-
- async def test_mcp_names_fallback_to_operation_id_short(
- self, mcp_names_openapi_spec, mock_client
- ):
- """Test fallback to operationId up to double underscore when not in mcp_names."""
- # Only provide mapping for one operationId
- mcp_names = {
- "list_users__with_pagination": "custom_user_list",
- }
-
- server = FastMCPOpenAPI(
- openapi_spec=mcp_names_openapi_spec,
- client=mock_client,
- mcp_names=mcp_names,
- route_maps=GET_ROUTE_MAPS,
- )
-
- tools_dict = await server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- tool_names = {tool.name for tool in tools}
-
- templates_dict = await server._resource_manager.get_resource_templates()
- template_names = {template.name for template in templates_dict.values()}
-
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {resource.name for resource in resources_dict.values()}
-
- # Custom mapped name should be used
- assert "custom_user_list" in resource_names
-
- # Unmapped operationIds should use short version (up to __)
- assert "create_user_admin" in tool_names
- assert "get_user_by_id" in template_names
-
- async def test_names_are_slugified(self, mcp_names_openapi_spec, mock_client):
- """Test that names are properly slugified (spaces, special chars removed)."""
- server = FastMCPOpenAPI(
- openapi_spec=mcp_names_openapi_spec,
- client=mock_client,
- route_maps=GET_ROUTE_MAPS,
- )
-
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {
- resource.name
- for resource in resources_dict.values()
- if resource.name is not None
- }
-
- # Special chars and spaces should be slugified
- slugified_name = next(
- (name for name in resource_names if "special" in name), None
- )
- assert slugified_name is not None
- # Should not contain special characters or spaces
- assert "@" not in slugified_name
- assert "#" not in slugified_name
- assert "$" not in slugified_name
- assert "%" not in slugified_name
- assert " " not in slugified_name
-
- async def test_names_are_truncated_to_56_chars(
- self, mcp_names_openapi_spec, mock_client
- ):
- """Test that names are truncated to 56 characters maximum."""
- server = FastMCPOpenAPI(
- openapi_spec=mcp_names_openapi_spec,
- client=mock_client,
- route_maps=GET_ROUTE_MAPS,
- )
-
- # Check all component types
- all_names = []
-
- tools_dict = await server._tool_manager.get_tools()
- all_names.extend(tool.name for tool in tools_dict.values())
-
- resources_dict = await server._resource_manager.get_resources()
- all_names.extend(resource.name for resource in resources_dict.values())
-
- templates_dict = await server._resource_manager.get_resource_templates()
- all_names.extend(template.name for template in templates_dict.values())
-
- # All names should be 56 characters or less
- for name in all_names:
- assert len(name) <= 56, (
- f"Name '{name}' exceeds 56 characters (length: {len(name)})"
- )
-
- # Verify that the long operationId was actually truncated
- long_name = next((name for name in all_names if len(name) > 50), None)
- assert long_name is not None, "Expected to find a truncated name for testing"
-
- async def test_mcp_names_with_from_openapi_classmethod(
- self, mcp_names_openapi_spec, mock_client
- ):
- """Test mcp_names works with FastMCP.from_openapi() classmethod."""
- mcp_names = {
- "list_users__with_pagination": "openapi_user_list",
- }
-
- server = FastMCP.from_openapi(
- openapi_spec=mcp_names_openapi_spec,
- client=mock_client,
- mcp_names=mcp_names,
- )
-
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {tool.name for tool in tools_dict.values()}
- assert "openapi_user_list" in tool_names
-
- async def test_mcp_names_with_from_fastapi_classmethod(self):
- """Test mcp_names works with FastMCP.from_fastapi() classmethod."""
- from fastapi import FastAPI
- from pydantic import BaseModel
-
- app = FastAPI(title="FastAPI MCP Names Test")
-
- class User(BaseModel):
- name: str
-
- @app.get("/users", operation_id="list_users__with_filters")
- async def get_users() -> list[User]:
- return [User(name="test")]
-
- @app.post("/users", operation_id="create_user__admin_required")
- async def create_user(user: User) -> User:
- return user
-
- mcp_names = {
- "list_users__with_filters": "fastapi_user_list",
- "create_user__admin_required": "fastapi_create_user",
- }
-
- server = FastMCP.from_fastapi(
- app=app,
- mcp_names=mcp_names,
- )
-
- tools_dict = await server._tool_manager.get_tools()
- tool_names = {tool.name for tool in tools_dict.values()}
-
- assert "fastapi_create_user" in tool_names
- assert "fastapi_user_list" in tool_names
-
- async def test_mcp_names_custom_names_are_also_truncated(
- self, mcp_names_openapi_spec, mock_client
- ):
- """Test that custom names in mcp_names are also truncated to 56 characters."""
- # Provide a custom name that's longer than 56 characters
- very_long_custom_name = "this_is_a_very_long_custom_name_that_exceeds_fifty_six_characters_and_should_be_truncated"
-
- mcp_names = {
- "list_users__with_pagination": very_long_custom_name,
- }
-
- server = FastMCPOpenAPI(
- openapi_spec=mcp_names_openapi_spec,
- client=mock_client,
- mcp_names=mcp_names,
- route_maps=GET_ROUTE_MAPS,
- )
-
- resources_dict = await server._resource_manager.get_resources()
- resource_names = {
- resource.name
- for resource in resources_dict.values()
- if resource.name is not None
- }
-
- # Find the resource that should have the custom name
- truncated_name = next(
- (
- name
- for name in resource_names
- if "this_is_a_very_long_custom_name" in name
- ),
- None,
- )
- assert truncated_name is not None
- assert len(truncated_name) <= 56
- assert (
- len(truncated_name) == 56
- ) # Should be exactly 56 since original was longer
-
-
-class TestRouteMapMCPTags:
- """Tests for RouteMap mcp_tags functionality."""
-
- @pytest.fixture
- def simple_fastapi_app(self) -> FastAPI:
- """Create a simple FastAPI app for testing mcp_tags."""
- app = FastAPI(title="MCP Tags Test API")
-
- @app.get("/users", tags=["users"])
- async def get_users():
- """Get all users."""
- return [{"id": 1, "name": "Alice"}]
-
- @app.get("/users/{user_id}", tags=["users"])
- async def get_user(user_id: int):
- """Get user by ID."""
- return {"id": user_id, "name": f"User {user_id}"}
-
- @app.post("/users", tags=["users"])
- async def create_user(name: str):
- """Create a new user."""
- return {"id": 99, "name": name}
-
- return app
-
- @pytest.fixture
- async def mock_client(self) -> httpx.AsyncClient:
- """Mock client for testing."""
-
- async def _responder(request):
- return httpx.Response(200, json={"status": "ok"})
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- async def test_mcp_tags_added_to_tools(self, simple_fastapi_app, mock_client):
- """Test that mcp_tags are added to Tools created from routes."""
- # Create route map that adds custom tags to POST endpoints
- route_maps = [
- RouteMap(
- methods=["POST"],
- pattern=r".*",
- mcp_type=MCPType.TOOL,
- mcp_tags={"custom", "api-write"},
- ),
- # Default mapping for other routes
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=simple_fastapi_app.openapi(),
- client=mock_client,
- route_maps=route_maps,
- )
-
- # Get the POST tool
- tools_dict = await server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_user_tool = next((t for t in tools if "create_user" in t.name), None)
-
- assert create_user_tool is not None, "create_user tool not found"
-
- # Check that both original tags and mcp_tags are present
- assert "users" in create_user_tool.tags # Original OpenAPI tag
- assert "custom" in create_user_tool.tags # Added via mcp_tags
- assert "api-write" in create_user_tool.tags # Added via mcp_tags
-
- async def test_mcp_tags_added_to_resources(self, simple_fastapi_app, mock_client):
- """Test that mcp_tags are added to Resources created from routes."""
- # Create route map that adds custom tags to GET endpoints without path params
- route_maps = [
- RouteMap(
- methods=["GET"],
- pattern=r"^/users$", # Only match /users, not /users/{id}
- mcp_type=MCPType.RESOURCE,
- mcp_tags={"list-data", "public-api"},
- ),
- # Default mapping for other routes
- RouteMap(
- methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE_TEMPLATE
- ),
- RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=simple_fastapi_app.openapi(),
- client=mock_client,
- route_maps=route_maps,
- )
-
- # Get the resource
- resources_dict = await server._resource_manager.get_resources()
- resources = list(resources_dict.values())
- get_users_resource = next((r for r in resources if "get_users" in r.name), None)
-
- assert get_users_resource is not None, "get_users resource not found"
-
- # Check that both original tags and mcp_tags are present
- assert "users" in get_users_resource.tags # Original OpenAPI tag
- assert "list-data" in get_users_resource.tags # Added via mcp_tags
- assert "public-api" in get_users_resource.tags # Added via mcp_tags
-
- async def test_mcp_tags_added_to_resource_templates(
- self, simple_fastapi_app, mock_client
- ):
- """Test that mcp_tags are added to ResourceTemplates created from routes."""
- # Create route map that adds custom tags to GET endpoints with path params
- route_maps = [
- RouteMap(
- methods=["GET"],
- pattern=r".*\{.*\}.*", # Match routes with path parameters
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- mcp_tags={"detail-view", "parameterized"},
- ),
- # Default mapping for other routes
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=simple_fastapi_app.openapi(),
- client=mock_client,
- route_maps=route_maps,
- )
-
- # Get the resource template
- templates_dict = await server._resource_manager.get_resource_templates()
- templates = list(templates_dict.values())
- get_user_template = next((t for t in templates if "get_user" in t.name), None)
-
- assert get_user_template is not None, "get_user template not found"
-
- # Check that both original tags and mcp_tags are present
- assert "users" in get_user_template.tags # Original OpenAPI tag
- assert "detail-view" in get_user_template.tags # Added via mcp_tags
- assert "parameterized" in get_user_template.tags # Added via mcp_tags
-
- async def test_multiple_route_maps_with_different_mcp_tags(
- self, simple_fastapi_app, mock_client
- ):
- """Test that different route maps can add different mcp_tags."""
- # Multiple route maps with different mcp_tags
- route_maps = [
- # First priority: POST requests get write-related tags
- RouteMap(
- methods=["POST"],
- pattern=r".*",
- mcp_type=MCPType.TOOL,
- mcp_tags={"write-operation", "mutation"},
- ),
- # Second priority: GET with path params get detail tags
- RouteMap(
- methods=["GET"],
- pattern=r".*\{.*\}.*",
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- mcp_tags={"detail", "single-item"},
- ),
- # Third priority: Other GET requests get list tags
- RouteMap(
- methods=["GET"],
- pattern=r".*",
- mcp_type=MCPType.RESOURCE,
- mcp_tags={"list", "collection"},
- ),
- ]
-
- server = FastMCPOpenAPI(
- openapi_spec=simple_fastapi_app.openapi(),
- client=mock_client,
- route_maps=route_maps,
- )
-
- # Check tool tags
- tools_dict = await server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_tool = next((t for t in tools if "create_user" in t.name), None)
- assert create_tool is not None
- assert "write-operation" in create_tool.tags
- assert "mutation" in create_tool.tags
-
- # Check resource template tags
- templates_dict = await server._resource_manager.get_resource_templates()
- templates = list(templates_dict.values())
- detail_template = next((t for t in templates if "get_user" in t.name), None)
- assert detail_template is not None
- assert "detail" in detail_template.tags
- assert "single-item" in detail_template.tags
-
- # Check resource tags
- resources_dict = await server._resource_manager.get_resources()
- resources = list(resources_dict.values())
- list_resource = next((r for r in resources if "get_users" in r.name), None)
- assert list_resource is not None
- assert "list" in list_resource.tags
- assert "collection" in list_resource.tags
-
-
-class TestGlobalTagsParameter:
- """Tests for the global tags parameter on from_openapi and from_fastapi class methods."""
-
- @pytest.fixture
- def simple_fastapi_app(self) -> FastAPI:
- """Create a simple FastAPI app for testing global tags."""
- app = FastAPI(title="Global Tags Test API")
-
- @app.get("/items", tags=["items"])
- async def get_items():
- """Get all items."""
- return [{"id": 1, "name": "Item 1"}]
-
- @app.get("/items/{item_id}", tags=["items"])
- async def get_item(item_id: int):
- """Get item by ID."""
- return {"id": item_id, "name": f"Item {item_id}"}
-
- @app.post("/items", tags=["items"])
- async def create_item(name: str):
- """Create a new item."""
- return {"id": 99, "name": name}
-
- return app
-
- @pytest.fixture
- async def mock_client(self) -> httpx.AsyncClient:
- """Mock client for testing."""
-
- async def _responder(request):
- return httpx.Response(200, json={"status": "ok"})
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- async def test_from_fastapi_adds_global_tags(self, simple_fastapi_app):
- """Test that from_fastapi adds global tags to all components."""
- global_tags = {"global", "api-v1"}
-
- server = FastMCP.from_fastapi(
- simple_fastapi_app,
- tags=global_tags,
- route_maps=[
- RouteMap(
- methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE
- ),
- RouteMap(
- methods=["GET"],
- pattern=r".*\{.*\}.*",
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- ),
- RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
- ],
- )
-
- # Check tool has both original and global tags
- tools = await server.get_tools()
- create_item_tool = tools["create_item_items_post"]
- assert "items" in create_item_tool.tags # Original OpenAPI tag
- assert "global" in create_item_tool.tags # Global tag
- assert "api-v1" in create_item_tool.tags # Global tag
-
- # Check resource has both original and global tags
- resources = await server.get_resources()
- get_items_resource = resources["resource://get_items_items_get"]
- assert "items" in get_items_resource.tags # Original OpenAPI tag
- assert "global" in get_items_resource.tags # Global tag
- assert "api-v1" in get_items_resource.tags # Global tag
-
- # Check resource template has both original and global tags
- templates = await server.get_resource_templates()
- get_item_template = templates["resource://get_item_items/{item_id}"]
- assert "items" in get_item_template.tags # Original OpenAPI tag
- assert "global" in get_item_template.tags # Global tag
- assert "api-v1" in get_item_template.tags # Global tag
-
- async def test_from_openapi_adds_global_tags(self, simple_fastapi_app, mock_client):
- """Test that from_openapi adds global tags to all components."""
- global_tags = {"openapi-global", "service"}
-
- server = FastMCP.from_openapi(
- openapi_spec=simple_fastapi_app.openapi(),
- client=mock_client,
- tags=global_tags,
- route_maps=[
- RouteMap(
- methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE
- ),
- RouteMap(
- methods=["GET"],
- pattern=r".*\{.*\}.*",
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- ),
- RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
- ],
- )
-
- # Check tool has both original and global tags
- tools = await server.get_tools()
- create_item_tool = tools["create_item_items_post"]
- assert "items" in create_item_tool.tags # Original OpenAPI tag
- assert "openapi-global" in create_item_tool.tags # Global tag
- assert "service" in create_item_tool.tags # Global tag
-
- # Check resource has both original and global tags
- resources = await server.get_resources()
- get_items_resource = resources["resource://get_items_items_get"]
- assert "items" in get_items_resource.tags # Original OpenAPI tag
- assert "openapi-global" in get_items_resource.tags # Global tag
- assert "service" in get_items_resource.tags # Global tag
-
- # Check resource template has both original and global tags
- templates = await server.get_resource_templates()
- get_item_template = templates["resource://get_item_items/{item_id}"]
- assert "items" in get_item_template.tags # Original OpenAPI tag
- assert "openapi-global" in get_item_template.tags # Global tag
- assert "service" in get_item_template.tags # Global tag
-
- async def test_global_tags_combine_with_route_map_tags(
- self, simple_fastapi_app, mock_client
- ):
- """Test that global tags combine with both OpenAPI tags and RouteMap mcp_tags."""
- global_tags = {"global"}
- route_map_tags = {"route-specific"}
-
- server = FastMCP.from_openapi(
- openapi_spec=simple_fastapi_app.openapi(),
- client=mock_client,
- tags=global_tags,
- route_maps=[
- RouteMap(
- methods=["POST"],
- pattern=r".*",
- mcp_type=MCPType.TOOL,
- mcp_tags=route_map_tags,
- ),
- RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
- ],
- )
-
- # Check that all three types of tags are present on the tool
- tools = await server.get_tools()
- create_item_tool = tools["create_item_items_post"]
- assert "items" in create_item_tool.tags # Original OpenAPI tag
- assert "global" in create_item_tool.tags # Global tag
- assert "route-specific" in create_item_tool.tags # RouteMap mcp_tag
-
- # Check that resource only has OpenAPI and global tags (no route-specific since different RouteMap)
- resources = await server.get_resources()
- get_items_resource = resources["resource://get_items_items_get"]
- assert "items" in get_items_resource.tags # Original OpenAPI tag
- assert "global" in get_items_resource.tags # Global tag
- assert "route-specific" not in get_items_resource.tags # Not from this RouteMap
diff --git a/tests/server/openapi/test_deepobject_style.py b/tests/server/openapi/test_deepobject_style.py
index b066c37b8..1d6f98d97 100644
--- a/tests/server/openapi/test_deepobject_style.py
+++ b/tests/server/openapi/test_deepobject_style.py
@@ -1,30 +1,27 @@
-"""Integration test for OpenAPI deepObject style parameter handling.
-
-This test verifies that the deepObject style and explode properties are correctly
-parsed from OpenAPI specifications and properly applied during HTTP request serialization.
-"""
-
-from unittest.mock import AsyncMock, MagicMock
+"""Tests for deepObject style parameter handling in openapi_new."""
import httpx
+import pytest
-from fastmcp.server.openapi import OpenAPITool
-from fastmcp.utilities.openapi import parse_openapi_to_http_routes
+from fastmcp.client import Client
+from fastmcp.server.openapi import FastMCPOpenAPI
class TestDeepObjectStyle:
- """Test the complete pipeline from OpenAPI spec to HTTP request parameters for deepObject style."""
+ """Test deepObject style parameter handling in openapi_new."""
- def test_deepobject_style_parsing_from_openapi_spec(self):
- """Test that deepObject style is correctly parsed from OpenAPI specification."""
- # Real OpenAPI spec with style: deepObject and explode: true
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
+ @pytest.fixture
+ def deepobject_spec(self):
+ """OpenAPI spec with deepObject style parameters."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "DeepObject Test API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
"paths": {
- "/api/surveys": {
+ "/surveys": {
"get": {
- "operationId": "getSurveys",
+ "operationId": "get_surveys",
+ "summary": "Get surveys with deepObject filtering",
"parameters": [
{
"name": "target",
@@ -37,62 +34,20 @@ class TestDeepObjectStyle:
"properties": {
"id": {
"type": "string",
- "description": "Valid ID for an object",
+ "description": "Target ID",
},
"type": {
"type": "string",
"enum": ["location", "organisation"],
- "description": "The type of object for given id",
+ "description": "Target type",
},
},
"required": ["type", "id"],
},
- }
- ],
- "responses": {
- "200": {
- "description": "Success",
- "content": {
- "application/json": {"schema": {"type": "integer"}}
- },
- }
- },
- }
- }
- },
- }
-
- # Parse the spec
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
- parameter = route.parameters[0]
-
- # Verify style and explode properties were captured correctly
- assert parameter.name == "target"
- assert parameter.location == "query"
- assert parameter.style == "deepObject", (
- f"Expected style='deepObject', got {parameter.style}"
- )
- assert parameter.explode is True, (
- f"Expected explode=True, got {parameter.explode}"
- )
-
- async def test_deepobject_style_request_serialization(self):
- """Test that deepObject style results in bracketed query parameters in HTTP requests.
-
- This is the critical integration test that reproduces the GitHub issue.
- """
- # OpenAPI spec matching the GitHub issue example
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/api/surveys": {
- "get": {
- "operationId": "getSurveys",
- "parameters": [
+ "description": "Target object for filtering",
+ },
{
- "name": "target",
+ "name": "filters",
"in": "query",
"required": False,
"style": "deepObject",
@@ -100,182 +55,279 @@ class TestDeepObjectStyle:
"schema": {
"type": "object",
"properties": {
- "id": {"type": "string"},
- "type": {"type": "string"},
+ "status": {"type": "string"},
+ "category": {"type": "string"},
+ "priority": {"type": "integer"},
},
- "required": ["type", "id"],
},
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- # Parse and create tool
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
-
- # Mock HTTP client
- mock_client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {}
- mock_response.raise_for_status.return_value = None
- mock_client.request.return_value = mock_response
-
- # Create tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="getSurveys",
- description="Get surveys",
- parameters={},
- )
-
- # Execute tool with object parameter (as it would come from user input)
- await tool.run(
- {"target": {"id": "57dc372a81b610496e8b465e", "type": "organisation"}}
- )
-
- # Verify the HTTP request was made with deepObject-style parameters
- mock_client.request.assert_called_once()
- call_kwargs = mock_client.request.call_args.kwargs
-
- # Check that params contains bracketed parameters, not JSON string
- params = call_kwargs.get("params", {})
-
- # Should have target[id] and target[type] parameters
- assert "target[id]" in params, "target[id] parameter should be present"
- assert "target[type]" in params, "target[type] parameter should be present"
-
- # Values should be correctly set
- assert params["target[id]"] == "57dc372a81b610496e8b465e", (
- f"Expected target[id]=57dc372a81b610496e8b465e, got {params.get('target[id]')}"
- )
- assert params["target[type]"] == "organisation", (
- f"Expected target[type]=organisation, got {params.get('target[type]')}"
- )
-
- # Should NOT have the original parameter name as JSON
- assert "target" not in params, (
- "Original 'target' parameter should not be present when using deepObject style"
- )
-
- async def test_deepobject_style_with_explode_false(self):
- """Test that deepObject style with explode=false falls back to JSON serialization."""
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/api/surveys": {
- "get": {
- "operationId": "getSurveys",
- "parameters": [
+ "description": "Additional filters",
+ },
{
- "name": "target",
+ "name": "compact",
"in": "query",
+ "required": False,
"style": "deepObject",
- "explode": False, # Non-standard combination
+ "explode": False,
"schema": {
"type": "object",
"properties": {
- "id": {"type": "string"},
- "type": {"type": "string"},
+ "format": {"type": "string"},
+ "level": {"type": "integer"},
},
},
- }
+ "description": "Compact format options (explode=false)",
+ },
],
- "responses": {"200": {"description": "Success"}},
+ "responses": {
+ "200": {
+ "description": "Survey list",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "surveys": {
+ "type": "array",
+ "items": {"type": "object"},
+ },
+ "total": {"type": "integer"},
+ },
+ }
+ }
+ },
+ }
+ },
}
- }
- },
- }
-
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
-
- mock_client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {}
- mock_response.raise_for_status.return_value = None
- mock_client.request.return_value = mock_response
-
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="getSurveys",
- description="Get surveys",
- parameters={},
- )
-
- await tool.run({"target": {"id": "123", "type": "test"}})
-
- mock_client.request.assert_called_once()
- call_kwargs = mock_client.request.call_args.kwargs
-
- params = call_kwargs.get("params", {})
-
- # Should fall back to JSON serialization
- assert "target" in params, "target parameter should be present"
- assert params["target"] == '{"id": "123", "type": "test"}', (
- f"Expected JSON string fallback, got {params.get('target')}"
- )
-
- async def test_non_object_with_deepobject_style(self):
- """Test that non-object parameters with deepObject style are handled gracefully."""
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/api/test": {
- "get": {
- "operationId": "testEndpoint",
+ },
+ "/users/{id}/preferences": {
+ "patch": {
+ "operationId": "update_preferences",
+ "summary": "Update user preferences with deepObject in body",
"parameters": [
{
- "name": "param",
- "in": "query",
- "style": "deepObject",
- "explode": True,
- "schema": {"type": "string"}, # Not an object
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
}
],
- "responses": {"200": {"description": "Success"}},
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "preferences": {
+ "type": "object",
+ "properties": {
+ "theme": {"type": "string"},
+ "notifications": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "boolean"
+ },
+ "push": {"type": "boolean"},
+ "frequency": {
+ "type": "string"
+ },
+ },
+ },
+ "privacy": {
+ "type": "object",
+ "properties": {
+ "profile_visible": {
+ "type": "boolean"
+ },
+ "analytics": {
+ "type": "boolean"
+ },
+ },
+ },
+ },
+ "description": "Nested preference object",
+ }
+ },
+ "required": ["preferences"],
+ }
+ }
+ },
+ },
+ "responses": {
+ "200": {
+ "description": "Preferences updated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {"type": "boolean"}
+ },
+ }
+ }
+ },
+ }
+ },
}
- }
+ },
},
}
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
+ async def test_deepobject_style_parsing_from_spec(self, deepobject_spec):
+ """Test that deepObject style parameters are correctly parsed from OpenAPI spec."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=deepobject_spec,
+ client=client,
+ name="DeepObject Test Server",
+ )
- mock_client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {}
- mock_response.raise_for_status.return_value = None
- mock_client.request.return_value = mock_response
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="testEndpoint",
- description="Test endpoint",
- parameters={},
- )
+ # Find the surveys tool
+ surveys_tool = next(
+ tool for tool in tools if tool.name == "get_surveys"
+ )
+ assert surveys_tool is not None
- # Pass a string value instead of an object
- await tool.run({"param": "test_value"})
+ # Check that deepObject parameters are included in schema
+ params = surveys_tool.inputSchema
+ properties = params["properties"]
- mock_client.request.assert_called_once()
- call_kwargs = mock_client.request.call_args.kwargs
+ # Should have the deepObject parameters
+ assert "target" in properties
+ assert "filters" in properties
+ assert "compact" in properties
- params = call_kwargs.get("params", {})
+ # Check that target parameter is present
+ # (Exact schema structure may vary based on implementation)
+ target_param = properties["target"]
+ # Should have some structure, exact format may vary
+ assert target_param is not None
- # Should use the parameter as-is since it's not an object
- assert "param" in params, "param parameter should be present"
- assert params["param"] == "test_value", (
- f"Expected 'test_value', got {params.get('param')}"
- )
+ async def test_deepobject_explode_true_handling(self, deepobject_spec):
+ """Test deepObject with explode=true parameter handling."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=deepobject_spec,
+ client=client,
+ name="DeepObject Test Server",
+ )
+
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+ surveys_tool = next(
+ tool for tool in tools if tool.name == "get_surveys"
+ )
+
+ # Check that explode=true parameters are properly structured
+ params = surveys_tool.inputSchema
+ properties = params["properties"]
+
+ # Target parameter with explode=true should allow individual property access
+ target_properties = properties["target"]["properties"]
+ assert "id" in target_properties
+ assert "type" in target_properties
+ assert target_properties["type"]["enum"] == ["location", "organisation"]
+
+ async def test_deepobject_explode_false_handling(self, deepobject_spec):
+ """Test deepObject with explode=false parameter handling."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=deepobject_spec,
+ client=client,
+ name="DeepObject Test Server",
+ )
+
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+ surveys_tool = next(
+ tool for tool in tools if tool.name == "get_surveys"
+ )
+
+ # Check that explode=false parameters are handled
+ params = surveys_tool.inputSchema
+ properties = params["properties"]
+
+ # Compact parameter with explode=false should still be present and valid
+ assert "compact" in properties
+ compact_param = properties["compact"]
+ # Check that it's a valid parameter (exact structure may vary)
+ assert compact_param is not None
+ # If it has a type, it should be object
+ if "type" in compact_param:
+ assert compact_param["type"] == "object"
+
+ async def test_nested_object_structure_in_request_body(self, deepobject_spec):
+ """Test nested object structures in request body are preserved."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=deepobject_spec,
+ client=client,
+ name="DeepObject Test Server",
+ )
+
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+
+ # Find the preferences tool
+ prefs_tool = next(
+ tool for tool in tools if tool.name == "update_preferences"
+ )
+ assert prefs_tool is not None
+
+ # Check that nested object structure is preserved
+ params = prefs_tool.inputSchema
+ properties = params["properties"]
+
+ # Should have path parameter
+ assert "id" in properties
+
+ # Should have preferences object
+ assert "preferences" in properties
+ prefs_param = properties["preferences"]
+ assert prefs_param["type"] == "object"
+
+ # Check nested structure
+ prefs_props = prefs_param["properties"]
+ assert "theme" in prefs_props
+ assert "notifications" in prefs_props
+ assert "privacy" in prefs_props
+
+ # Check deeply nested objects
+ notifications = prefs_props["notifications"]
+ assert notifications["type"] == "object"
+ notif_props = notifications["properties"]
+ assert "email" in notif_props
+ assert "push" in notif_props
+ assert "frequency" in notif_props
+
+ async def test_deepobject_tool_functionality(self, deepobject_spec):
+ """Test that tools with deepObject parameters maintain basic functionality."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=deepobject_spec,
+ client=client,
+ name="DeepObject Test Server",
+ )
+
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+
+ # Should successfully create tools with deepObject parameters
+ assert len(tools) == 2
+
+ tool_names = {tool.name for tool in tools}
+ assert "get_surveys" in tool_names
+ assert "update_preferences" in tool_names
+
+ # All tools should have valid schemas
+ for tool in tools:
+ assert tool.inputSchema is not None
+ assert tool.inputSchema["type"] == "object"
+ assert "properties" in tool.inputSchema
+
+ # Should have some properties
+ assert len(tool.inputSchema["properties"]) > 0
diff --git a/tests/server/openapi/test_description_propagation.py b/tests/server/openapi/test_description_propagation.py
deleted file mode 100644
index a851a19a8..000000000
--- a/tests/server/openapi/test_description_propagation.py
+++ /dev/null
@@ -1,796 +0,0 @@
-import httpx
-import pytest
-from fastapi import FastAPI
-from httpx import ASGITransport, AsyncClient
-
-from fastmcp import FastMCP
-from fastmcp.client import Client
-from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap
-
-from .conftest import GET_ROUTE_MAPS
-
-
-class TestDescriptionPropagation:
- """Tests for OpenAPI description propagation to FastMCP components.
-
- Each test focuses on a single, specific behavior to make it immediately clear
- what's broken when a test fails.
- """
-
- @pytest.fixture
- def simple_openapi_spec(self) -> dict:
- """Create a minimal OpenAPI spec with obvious test descriptions."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/items": {
- "get": {
- "operationId": "listItems",
- "summary": "List items summary",
- "description": "LIST_DESCRIPTION\n\nFUNCTION_LIST_DESCRIPTION",
- "responses": {
- "200": {
- "description": "LIST_RESPONSE_DESCRIPTION",
- "content": {
- "application/json": {
- "schema": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "description": "ITEM_RESPONSE_ID_DESCRIPTION",
- },
- "name": {
- "type": "string",
- "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
- },
- "price": {
- "type": "number",
- "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
- },
- },
- },
- },
- }
- },
- }
- },
- }
- },
- "/items/{item_id}": {
- "get": {
- "operationId": "getItem",
- "summary": "Get item summary",
- "description": "GET_DESCRIPTION\n\nFUNCTION_GET_DESCRIPTION",
- "parameters": [
- {
- "name": "item_id",
- "in": "path",
- "required": True,
- "description": "PATH_PARAM_DESCRIPTION",
- "schema": {"type": "string"},
- },
- {
- "name": "fields",
- "in": "query",
- "required": False,
- "description": "QUERY_PARAM_DESCRIPTION",
- "schema": {"type": "string"},
- },
- ],
- "responses": {
- "200": {
- "description": "GET_RESPONSE_DESCRIPTION",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "description": "ITEM_RESPONSE_ID_DESCRIPTION",
- },
- "name": {
- "type": "string",
- "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
- },
- "price": {
- "type": "number",
- "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
- },
- },
- },
- }
- },
- }
- },
- }
- },
- "/items/create": {
- "post": {
- "operationId": "createItem",
- "summary": "Create item summary",
- "description": "CREATE_DESCRIPTION\n\nFUNCTION_CREATE_DESCRIPTION",
- "requestBody": {
- "required": True,
- "description": "BODY_DESCRIPTION",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "description": "PROP_DESCRIPTION",
- }
- },
- "required": ["name"],
- }
- }
- },
- },
- "responses": {
- "201": {
- "description": "CREATE_RESPONSE_DESCRIPTION",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "description": "ITEM_RESPONSE_ID_DESCRIPTION",
- },
- "name": {
- "type": "string",
- "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
- },
- },
- },
- }
- },
- }
- },
- }
- },
- },
- }
-
- @pytest.fixture
- async def mock_client(self) -> httpx.AsyncClient:
- """Create a mock client that returns simple responses."""
-
- async def _responder(request):
- if request.url.path == "/items" and request.method == "GET":
- return httpx.Response(200, json=[{"id": "1", "name": "Item 1"}])
- elif request.url.path.startswith("/items/") and request.method == "GET":
- item_id = request.url.path.split("/")[-1]
- return httpx.Response(
- 200, json={"id": item_id, "name": f"Item {item_id}"}
- )
- elif request.url.path == "/items/create" and request.method == "POST":
- import json
-
- data = json.loads(request.content)
- return httpx.Response(201, json={"id": "new", "name": data.get("name")})
-
- return httpx.Response(404)
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- @pytest.fixture
- async def simple_mcp_server(self, simple_openapi_spec, mock_client):
- """Create a FastMCPOpenAPI server with the simple test spec."""
- return FastMCPOpenAPI(
- openapi_spec=simple_openapi_spec,
- client=mock_client,
- name="Test API",
- route_maps=GET_ROUTE_MAPS,
- )
-
- # --- RESOURCE TESTS ---
-
- async def test_resource_includes_route_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a Resource includes the route description."""
- resources = list(
- (await simple_mcp_server._resource_manager.get_resources()).values()
- )
- list_resource = next((r for r in resources if r.name == "listItems"), None)
-
- assert list_resource is not None, "listItems resource wasn't created"
- assert "LIST_DESCRIPTION" in (list_resource.description or ""), (
- "Route description missing from Resource"
- )
-
- async def test_resource_includes_response_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a Resource includes the response description."""
- resources = list(
- (await simple_mcp_server._resource_manager.get_resources()).values()
- )
- list_resource = next((r for r in resources if r.name == "listItems"), None)
-
- assert list_resource is not None, "listItems resource wasn't created"
- assert "LIST_RESPONSE_DESCRIPTION" in (list_resource.description or ""), (
- "Response description missing from Resource"
- )
-
- async def test_resource_includes_response_model_fields(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a Resource description includes response model field descriptions."""
- resources = list(
- (await simple_mcp_server._resource_manager.get_resources()).values()
- )
- list_resource = next((r for r in resources if r.name == "listItems"), None)
-
- assert list_resource is not None, "listItems resource wasn't created"
- description = list_resource.description or ""
- assert "ITEM_RESPONSE_ID_DESCRIPTION" in description, (
- "Response model field descriptions missing from Resource description"
- )
- assert "ITEM_RESPONSE_NAME_DESCRIPTION" in description, (
- "Response model field descriptions missing from Resource description"
- )
- assert "ITEM_RESPONSE_PRICE_DESCRIPTION" in description, (
- "Response model field descriptions missing from Resource description"
- )
-
- # --- RESOURCE TEMPLATE TESTS ---
-
- async def test_template_includes_route_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a ResourceTemplate includes the route description."""
- templates_dict = (
- await simple_mcp_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if t.name == "getItem"), None)
-
- assert get_template is not None, "getItem template wasn't created"
- assert "GET_DESCRIPTION" in (get_template.description or ""), (
- "Route description missing from ResourceTemplate"
- )
-
- async def test_template_includes_function_docstring(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a ResourceTemplate includes the function docstring."""
- templates_dict = (
- await simple_mcp_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if t.name == "getItem"), None)
-
- assert get_template is not None, "getItem template wasn't created"
- assert "FUNCTION_GET_DESCRIPTION" in (get_template.description or ""), (
- "Function docstring missing from ResourceTemplate"
- )
-
- async def test_template_includes_path_parameter_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a ResourceTemplate includes path parameter descriptions."""
- templates_dict = (
- await simple_mcp_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if t.name == "getItem"), None)
-
- assert get_template is not None, "getItem template wasn't created"
- assert "PATH_PARAM_DESCRIPTION" in (get_template.description or ""), (
- "Path parameter description missing from ResourceTemplate description"
- )
-
- async def test_template_includes_query_parameter_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a ResourceTemplate includes query parameter descriptions."""
- templates_dict = (
- await simple_mcp_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if t.name == "getItem"), None)
-
- assert get_template is not None, "getItem template wasn't created"
- assert "QUERY_PARAM_DESCRIPTION" in (get_template.description or ""), (
- "Query parameter description missing from ResourceTemplate description"
- )
-
- async def test_template_parameter_schema_includes_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
- templates_dict = (
- await simple_mcp_server._resource_manager.get_resource_templates()
- )
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if t.name == "getItem"), None)
-
- assert get_template is not None, "getItem template wasn't created"
- assert "properties" in get_template.parameters, (
- "Schema properties missing from ResourceTemplate"
- )
- assert "item_id" in get_template.parameters["properties"], (
- "item_id missing from ResourceTemplate schema"
- )
- assert "description" in get_template.parameters["properties"]["item_id"], (
- "Description missing from item_id parameter schema"
- )
- assert (
- "PATH_PARAM_DESCRIPTION"
- in get_template.parameters["properties"]["item_id"]["description"]
- ), "Path parameter description incorrect in schema"
-
- # --- TOOL TESTS ---
-
- async def test_tool_includes_route_description(self, simple_mcp_server: FastMCP):
- """Test that a Tool includes the route description."""
- tools_dict = await simple_mcp_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_tool = next((t for t in tools if t.name == "createItem"), None)
-
- assert create_tool is not None, "createItem tool wasn't created"
- assert "CREATE_DESCRIPTION" in (create_tool.description or ""), (
- "Route description missing from Tool"
- )
-
- async def test_tool_includes_function_docstring(self, simple_mcp_server: FastMCP):
- """Test that a Tool includes the function docstring."""
- tools_dict = await simple_mcp_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_tool = next((t for t in tools if t.name == "createItem"), None)
-
- assert create_tool is not None, "createItem tool wasn't created"
- description = create_tool.description or ""
- assert "FUNCTION_CREATE_DESCRIPTION" in description, (
- "Function docstring missing from Tool"
- )
-
- async def test_tool_parameter_schema_includes_property_description(
- self, simple_mcp_server: FastMCP
- ):
- """Test that a Tool's parameter schema includes property descriptions from request model."""
- tools_dict = await simple_mcp_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_tool = next((t for t in tools if t.name == "createItem"), None)
-
- assert create_tool is not None, "createItem tool wasn't created"
- assert "properties" in create_tool.parameters, (
- "Schema properties missing from Tool"
- )
- assert "name" in create_tool.parameters["properties"], (
- "name parameter missing from Tool schema"
- )
- assert "description" in create_tool.parameters["properties"]["name"], (
- "Description missing from name parameter schema"
- )
- assert (
- "PROP_DESCRIPTION"
- in create_tool.parameters["properties"]["name"]["description"]
- ), "Property description incorrect in schema"
-
- # --- CLIENT API TESTS ---
-
- async def test_client_api_resource_description(self, simple_mcp_server: FastMCP):
- """Test that Resource descriptions are accessible via the client API."""
- async with Client(simple_mcp_server) as client:
- resources = await client.list_resources()
- list_resource = next((r for r in resources if r.name == "listItems"), None)
-
- assert list_resource is not None, (
- "listItems resource not accessible via client API"
- )
- resource_description = list_resource.description or ""
- assert "LIST_DESCRIPTION" in resource_description, (
- "Route description missing in Resource from client API"
- )
-
- async def test_client_api_template_description(self, simple_mcp_server: FastMCP):
- """Test that ResourceTemplate descriptions are accessible via the client API."""
- async with Client(simple_mcp_server) as client:
- templates = await client.list_resource_templates()
- get_template = next((t for t in templates if t.name == "getItem"), None)
-
- assert get_template is not None, (
- "getItem template not accessible via client API"
- )
- template_description = get_template.description or ""
- assert "GET_DESCRIPTION" in template_description, (
- "Route description missing in ResourceTemplate from client API"
- )
-
- async def test_client_api_tool_description(self, simple_mcp_server: FastMCP):
- """Test that Tool descriptions are accessible via the client API."""
- async with Client(simple_mcp_server) as client:
- tools = await client.list_tools()
- create_tool = next((t for t in tools if t.name == "createItem"), None)
-
- assert create_tool is not None, (
- "createItem tool not accessible via client API"
- )
- tool_description = create_tool.description or ""
- assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
- "Function docstring missing in Tool from client API"
- )
-
- async def test_client_api_tool_parameter_schema(self, simple_mcp_server: FastMCP):
- """Test that Tool parameter schemas are accessible via the client API."""
- async with Client(simple_mcp_server) as client:
- tools = await client.list_tools()
- create_tool = next((t for t in tools if t.name == "createItem"), None)
-
- assert create_tool is not None, (
- "createItem tool not accessible via client API"
- )
- assert "properties" in create_tool.inputSchema, (
- "Schema properties missing from Tool inputSchema in client API"
- )
- assert "name" in create_tool.inputSchema["properties"], (
- "name parameter missing from Tool schema in client API"
- )
- assert "description" in create_tool.inputSchema["properties"]["name"], (
- "Description missing from name parameter in client API"
- )
- assert (
- "PROP_DESCRIPTION"
- in create_tool.inputSchema["properties"]["name"]["description"]
- ), "Property description incorrect in schema from client API"
-
-
-class TestFastAPIDescriptionPropagation:
- """Tests for FastAPI docstring and annotation propagation to FastMCP components.
-
- Each test focuses on a single, specific behavior to make it immediately clear
- what's broken when a test fails.
- """
-
- @pytest.fixture
- def fastapi_app_with_descriptions(self) -> FastAPI:
- """Create a simple FastAPI app with docstrings and annotations."""
- from typing import Annotated
-
- from pydantic import BaseModel, Field
-
- app = FastAPI(title="Test FastAPI App")
-
- class Item(BaseModel):
- name: str = Field(..., description="ITEM_NAME_DESCRIPTION")
- price: float = Field(..., description="ITEM_PRICE_DESCRIPTION")
-
- class ItemResponse(BaseModel):
- id: str = Field(..., description="ITEM_RESPONSE_ID_DESCRIPTION")
- name: str = Field(..., description="ITEM_RESPONSE_NAME_DESCRIPTION")
- price: float = Field(..., description="ITEM_RESPONSE_PRICE_DESCRIPTION")
-
- @app.get("/items", tags=["items"])
- async def list_items() -> list[ItemResponse]:
- """FUNCTION_LIST_DESCRIPTION
-
- Returns a list of items.
- """
- return [
- ItemResponse(id="1", name="Item 1", price=10.0),
- ItemResponse(id="2", name="Item 2", price=20.0),
- ]
-
- @app.get("/items/{item_id}", tags=["items", "detail"])
- async def get_item(
- item_id: Annotated[str, Field(description="PATH_PARAM_DESCRIPTION")],
- fields: Annotated[
- str | None, Field(description="QUERY_PARAM_DESCRIPTION")
- ] = None,
- ) -> ItemResponse:
- """FUNCTION_GET_DESCRIPTION
-
- Gets a specific item by ID.
-
- Args:
- item_id: The ID of the item to retrieve
- fields: Optional fields to include
- """
- return ItemResponse(
- id=item_id, name=f"Item {item_id}", price=float(item_id) * 10.0
- )
-
- @app.post("/items", tags=["items", "create"])
- async def create_item(item: Item) -> ItemResponse:
- """FUNCTION_CREATE_DESCRIPTION
-
- Creates a new item.
-
- Body:
- Item object with name and price
- """
- return ItemResponse(id="new", name=item.name, price=item.price)
-
- return app
-
- @pytest.fixture
- async def fastapi_server(self, fastapi_app_with_descriptions):
- """Create a FastMCP server from the FastAPI app with custom route mappings."""
- # First create from FastAPI app to get the OpenAPI spec
- openapi_spec = fastapi_app_with_descriptions.openapi()
-
- # Debug: check the operationIds in the OpenAPI spec
- print("\nDEBUG - OpenAPI Paths:")
- for path, methods in openapi_spec["paths"].items():
- for method, details in methods.items():
- if method != "parameters": # Skip non-HTTP method keys
- operation_id = details.get("operationId", "no_operation_id")
- print(
- f" Path: {path}, Method: {method}, OperationId: {operation_id}"
- )
-
- # Create custom route mappings
- route_maps = [
- # Map GET /items to Resource
- RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
- # Map GET /items/{item_id} to ResourceTemplate
- RouteMap(
- methods=["GET"],
- pattern=r"^/items/\{.*\}$",
- mcp_type=MCPType.RESOURCE_TEMPLATE,
- ),
- # Map POST /items to Tool
- RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
- ]
-
- # Create FastMCP server with the OpenAPI spec and custom route mappings
- server = FastMCPOpenAPI(
- openapi_spec=openapi_spec,
- client=AsyncClient(
- transport=ASGITransport(app=fastapi_app_with_descriptions),
- base_url="http://test",
- ),
- name="Test FastAPI App",
- route_maps=route_maps,
- )
-
- # Debug: print all components created
- print("\nDEBUG - Resources created:")
- resources_dict = await server._resource_manager.get_resources()
- for name, resource in resources_dict.items():
- print(f" Resource: {name}, Name attribute: {resource.name}")
-
- print("\nDEBUG - Templates created:")
- templates_dict = await server._resource_manager.get_resource_templates()
- for name, template in templates_dict.items():
- print(f" Template: {name}, Name attribute: {template.name}")
-
- print("\nDEBUG - Tools created:")
- tools_dict = await server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- for tool in tools:
- print(f" Tool: {tool.name}")
-
- return server
-
- async def test_resource_includes_function_docstring(self, fastapi_server: FastMCP):
- """Test that a Resource includes the function docstring."""
- resources_dict = await fastapi_server._resource_manager.get_resources()
- resources = list(resources_dict.values())
-
- # Now checking for the get_items operation ID rather than list_items
- list_resource = next((r for r in resources if "items_get" in r.name), None)
-
- assert list_resource is not None, "GET /items resource wasn't created"
- description = list_resource.description or ""
- assert "FUNCTION_LIST_DESCRIPTION" in description, (
- "Function docstring missing from Resource"
- )
-
- async def test_resource_includes_response_model_fields(
- self, fastapi_server: FastMCP
- ):
- """Test that a Resource description includes basic response information.
-
- Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema,
- so we can only check for basic response information being present.
- """
- resources_dict = await fastapi_server._resource_manager.get_resources()
- resources = list(resources_dict.values())
- list_resource = next((r for r in resources if "items_get" in r.name), None)
-
- assert list_resource is not None, "GET /items resource wasn't created"
- description = list_resource.description or ""
-
- # Check that at least the response information is included
- assert "Successful Response" in description, (
- "Response information missing from Resource description"
- )
-
- # We've already verified in TestDescriptionPropagation that when descriptions
- # are present in the OpenAPI schema, they are properly included in the component description
-
- async def test_template_includes_function_docstring(self, fastapi_server: FastMCP):
- """Test that a ResourceTemplate includes the function docstring."""
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
-
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
- description = get_template.description or ""
- assert "FUNCTION_GET_DESCRIPTION" in description, (
- "Function docstring missing from ResourceTemplate"
- )
-
- async def test_template_includes_path_parameter_description(
- self, fastapi_server: FastMCP
- ):
- """Test that a ResourceTemplate includes path parameter descriptions.
-
- Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
- are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
- """
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
-
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
- description = get_template.description or ""
-
- # Just test that parameters are included at all
- assert "Path Parameters" in description, (
- "Path parameters section missing from ResourceTemplate description"
- )
- assert "item_id" in description, (
- "item_id parameter missing from ResourceTemplate description"
- )
-
- async def test_template_includes_query_parameter_description(
- self, fastapi_server: FastMCP
- ):
- """Test that a ResourceTemplate includes query parameter descriptions.
-
- Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
- are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
- """
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
-
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
- description = get_template.description or ""
-
- # Just test that parameters are included at all
- assert "Query Parameters" in description, (
- "Query parameters section missing from ResourceTemplate description"
- )
- assert "fields" in description, (
- "fields parameter missing from ResourceTemplate description"
- )
-
- async def test_template_parameter_schema_includes_description(
- self, fastapi_server: FastMCP
- ):
- """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
- templates_dict = await fastapi_server._resource_manager.get_resource_templates()
- templates = list(templates_dict.values())
- get_template = next((t for t in templates if "get_item_items" in t.name), None)
-
- assert get_template is not None, "GET /items/{item_id} template wasn't created"
- assert "properties" in get_template.parameters, (
- "Schema properties missing from ResourceTemplate"
- )
- assert "item_id" in get_template.parameters["properties"], (
- "item_id missing from ResourceTemplate schema"
- )
- assert "description" in get_template.parameters["properties"]["item_id"], (
- "Description missing from item_id parameter schema"
- )
- assert (
- "PATH_PARAM_DESCRIPTION"
- in get_template.parameters["properties"]["item_id"]["description"]
- ), "Path parameter description incorrect in schema"
-
- async def test_tool_includes_function_docstring(self, fastapi_server: FastMCP):
- """Test that a Tool includes the function docstring."""
- tools_dict = await fastapi_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_tool = next(
- (t for t in tools if "create_item_items_post" == t.name), None
- )
-
- assert create_tool is not None, "POST /items tool wasn't created"
- description = create_tool.description or ""
- assert "FUNCTION_CREATE_DESCRIPTION" in description, (
- "Function docstring missing from Tool"
- )
-
- async def test_tool_parameter_schema_includes_property_description(
- self, fastapi_server: FastMCP
- ):
- """Test that a Tool's parameter schema includes property descriptions from request model.
-
- Note: Currently, model field descriptions defined in Pydantic models using Field(description=...)
- may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's
- parameter schema.
- """
- tools_dict = await fastapi_server._tool_manager.get_tools()
- tools = list(tools_dict.values())
- create_tool = next(
- (t for t in tools if "create_item_items_post" == t.name), None
- )
-
- assert create_tool is not None, "POST /items tool wasn't created"
- assert "properties" in create_tool.parameters, (
- "Schema properties missing from Tool"
- )
- assert "name" in create_tool.parameters["properties"], (
- "name parameter missing from Tool schema"
- )
- # We don't test for the description field content as it may not be consistently propagated
-
- async def test_client_api_resource_description(self, fastapi_server: FastMCP):
- """Test that Resource descriptions are accessible via the client API."""
- async with Client(fastapi_server) as client:
- resources = await client.list_resources()
- list_resource = next((r for r in resources if "items_get" in r.name), None)
-
- assert list_resource is not None, (
- "GET /items resource not accessible via client API"
- )
- resource_description = list_resource.description or ""
- assert "FUNCTION_LIST_DESCRIPTION" in resource_description, (
- "Function docstring missing in Resource from client API"
- )
-
- async def test_client_api_template_description(self, fastapi_server: FastMCP):
- """Test that ResourceTemplate descriptions are accessible via the client API."""
- async with Client(fastapi_server) as client:
- templates = await client.list_resource_templates()
- get_template = next(
- (t for t in templates if "get_item_items" in t.name), None
- )
-
- assert get_template is not None, (
- "GET /items/{item_id} template not accessible via client API"
- )
- template_description = get_template.description or ""
- assert "FUNCTION_GET_DESCRIPTION" in template_description, (
- "Function docstring missing in ResourceTemplate from client API"
- )
-
- async def test_client_api_tool_description(self, fastapi_server: FastMCP):
- """Test that Tool descriptions are accessible via the client API."""
- async with Client(fastapi_server) as client:
- tools = await client.list_tools()
- create_tool = next(
- (t for t in tools if "create_item_items_post" == t.name), None
- )
-
- assert create_tool is not None, (
- "POST /items tool not accessible via client API"
- )
- tool_description = create_tool.description or ""
- assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
- "Function docstring missing in Tool from client API"
- )
-
- async def test_client_api_tool_parameter_schema(self, fastapi_server: FastMCP):
- """Test that Tool parameter schemas are accessible via the client API."""
- async with Client(fastapi_server) as client:
- tools = await client.list_tools()
- create_tool = next(
- (t for t in tools if "create_item_items_post" == t.name), None
- )
-
- assert create_tool is not None, (
- "POST /items tool not accessible via client API"
- )
- assert "properties" in create_tool.inputSchema, (
- "Schema properties missing from Tool inputSchema in client API"
- )
- assert "name" in create_tool.inputSchema["properties"], (
- "name parameter missing from Tool schema in client API"
- )
- # We don't test for the description field content as it may not be consistently propagated
diff --git a/tests/experimental/openapi_parser/server/openapi/test_end_to_end_compatibility.py b/tests/server/openapi/test_end_to_end_compatibility.py
similarity index 99%
rename from tests/experimental/openapi_parser/server/openapi/test_end_to_end_compatibility.py
rename to tests/server/openapi/test_end_to_end_compatibility.py
index a28669670..14827629f 100644
--- a/tests/experimental/openapi_parser/server/openapi/test_end_to_end_compatibility.py
+++ b/tests/server/openapi/test_end_to_end_compatibility.py
@@ -4,7 +4,7 @@ import httpx
import pytest
from fastmcp.client import Client
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
+from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.openapi import FastMCPOpenAPI as LegacyFastMCPOpenAPI
diff --git a/tests/server/openapi/test_explode_integration.py b/tests/server/openapi/test_explode_integration.py
deleted file mode 100644
index 8e96b5fc1..000000000
--- a/tests/server/openapi/test_explode_integration.py
+++ /dev/null
@@ -1,320 +0,0 @@
-"""Integration test for OpenAPI explode property handling.
-
-This test verifies that the explode property is correctly parsed from OpenAPI
-specifications and properly applied during HTTP request serialization.
-"""
-
-from unittest.mock import AsyncMock, MagicMock
-
-import httpx
-
-from fastmcp.server.openapi import OpenAPITool
-from fastmcp.utilities.openapi import parse_openapi_to_http_routes
-
-
-class TestExplodeIntegration:
- """Test the complete pipeline from OpenAPI spec to HTTP request parameters."""
-
- def test_explode_false_parsing_from_openapi_spec(self):
- """Test that explode=false is correctly parsed from OpenAPI specification."""
- # Real OpenAPI spec with explode: false
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search_items",
- "parameters": [
- {
- "name": "tags",
- "in": "query",
- "required": False,
- "style": "form",
- "explode": False, # This should be respected
- "schema": {
- "type": "array",
- "items": {"type": "string"},
- },
- }
- ],
- "responses": {
- "200": {
- "description": "Success",
- "content": {
- "application/json": {"schema": {"type": "object"}}
- },
- }
- },
- }
- }
- },
- }
-
- # Parse the spec
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
- parameter = route.parameters[0]
-
- # Verify explode property was captured correctly
- assert parameter.name == "tags"
- assert parameter.location == "query"
- assert parameter.explode is False, (
- f"Expected explode=False, got {parameter.explode}"
- )
-
- def test_explode_true_parsing_from_openapi_spec(self):
- """Test that explode=true is correctly parsed from OpenAPI specification."""
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search_items",
- "parameters": [
- {
- "name": "tags",
- "in": "query",
- "explode": True, # Explicitly set to true
- "schema": {
- "type": "array",
- "items": {"type": "string"},
- },
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- routes = parse_openapi_to_http_routes(openapi_spec)
- parameter = routes[0].parameters[0]
-
- assert parameter.explode is True, (
- f"Expected explode=True, got {parameter.explode}"
- )
-
- def test_explode_default_parsing_from_openapi_spec(self):
- """Test that missing explode defaults to None during parsing."""
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search_items",
- "parameters": [
- {
- "name": "tags",
- "in": "query",
- "schema": {
- "type": "array",
- "items": {"type": "string"},
- },
- # No explode property specified
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- routes = parse_openapi_to_http_routes(openapi_spec)
- parameter = routes[0].parameters[0]
-
- assert parameter.explode is None, (
- f"Expected explode=None, got {parameter.explode}"
- )
-
- async def test_explode_false_request_serialization(self):
- """Test that explode=false results in comma-separated query parameters in HTTP requests.
-
- This is the critical integration test that would have failed before the fix.
- """
- # OpenAPI spec with explode: false
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search_items",
- "parameters": [
- {
- "name": "tags",
- "in": "query",
- "explode": False,
- "schema": {
- "type": "array",
- "items": {"type": "string"},
- },
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- # Parse and create tool
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
-
- # Mock HTTP client
- mock_client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {}
- mock_response.raise_for_status.return_value = None
- mock_client.request.return_value = mock_response
-
- # Create tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="search_items",
- description="Search items",
- parameters={},
- )
-
- # Execute tool with array parameter
- await tool.run({"tags": ["red", "blue", "green"]})
-
- # Verify the HTTP request was made with comma-separated parameters
- mock_client.request.assert_called_once()
- call_kwargs = mock_client.request.call_args.kwargs
-
- # Check that params contains comma-separated values, not an array
- params = call_kwargs.get("params", {})
- assert "tags" in params, "tags parameter should be present"
-
- tags_value = params["tags"]
- assert isinstance(tags_value, str), (
- f"Expected string for explode=false, got {type(tags_value)}"
- )
- assert tags_value == "red,blue,green", (
- f"Expected 'red,blue,green', got '{tags_value}'"
- )
-
- async def test_explode_true_request_serialization(self):
- """Test that explode=true results in separate query parameters in HTTP requests."""
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search_items",
- "parameters": [
- {
- "name": "tags",
- "in": "query",
- "explode": True,
- "schema": {
- "type": "array",
- "items": {"type": "string"},
- },
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
-
- mock_client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {}
- mock_response.raise_for_status.return_value = None
- mock_client.request.return_value = mock_response
-
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="search_items",
- description="Search items",
- parameters={},
- )
-
- await tool.run({"tags": ["red", "blue", "green"]})
-
- mock_client.request.assert_called_once()
- call_kwargs = mock_client.request.call_args.kwargs
-
- params = call_kwargs.get("params", {})
- assert "tags" in params, "tags parameter should be present"
-
- tags_value = params["tags"]
- assert isinstance(tags_value, list), (
- f"Expected list for explode=true, got {type(tags_value)}"
- )
- assert tags_value == ["red", "blue", "green"], (
- f"Expected ['red', 'blue', 'green'], got {tags_value}"
- )
-
- async def test_explode_default_request_serialization(self):
- """Test that default behavior (no explode) uses explode=true for query parameters."""
- openapi_spec = {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/search": {
- "get": {
- "operationId": "search_items",
- "parameters": [
- {
- "name": "tags",
- "in": "query",
- "schema": {
- "type": "array",
- "items": {"type": "string"},
- },
- # No explode specified - should default to true for query params
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- routes = parse_openapi_to_http_routes(openapi_spec)
- route = routes[0]
-
- mock_client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {}
- mock_response.raise_for_status.return_value = None
- mock_client.request.return_value = mock_response
-
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="search_items",
- description="Search items",
- parameters={},
- )
-
- await tool.run({"tags": ["red", "blue", "green"]})
-
- mock_client.request.assert_called_once()
- call_kwargs = mock_client.request.call_args.kwargs
-
- params = call_kwargs.get("params", {})
- tags_value = params["tags"]
-
- # Default behavior should be explode=true (separate parameters)
- assert isinstance(tags_value, list), (
- f"Expected list for default behavior, got {type(tags_value)}"
- )
- assert tags_value == ["red", "blue", "green"], (
- f"Expected ['red', 'blue', 'green'], got {tags_value}"
- )
diff --git a/tests/server/openapi/test_openapi_compatibility.py b/tests/server/openapi/test_openapi_compatibility.py
deleted file mode 100644
index a32630dd0..000000000
--- a/tests/server/openapi/test_openapi_compatibility.py
+++ /dev/null
@@ -1,661 +0,0 @@
-import json
-
-import httpx
-import pytest
-from pydantic.networks import AnyUrl
-
-from fastmcp import FastMCP
-from fastmcp.client import Client
-from fastmcp.server.openapi import FastMCPOpenAPI
-from fastmcp.utilities.openapi import parse_openapi_to_http_routes
-
-from .conftest import GET_ROUTE_MAPS
-
-
-class TestOpenAPI30Compatibility:
- """Tests for compatibility with OpenAPI 3.0 specifications."""
-
- @pytest.fixture
- def openapi_30_spec(self) -> dict:
- """Fixture that returns a simple OpenAPI 3.0 specification."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Product API (3.0)", "version": "1.0.0"},
- "paths": {
- "/products": {
- "get": {
- "operationId": "listProducts",
- "summary": "List all products",
- "responses": {"200": {"description": "A list of products"}},
- },
- "post": {
- "operationId": "createProduct",
- "summary": "Create a new product",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "price": {"type": "number"},
- },
- "required": ["name", "price"],
- }
- }
- },
- },
- "responses": {"201": {"description": "Product created"}},
- },
- },
- "/products/{product_id}": {
- "get": {
- "operationId": "getProduct",
- "summary": "Get product by ID",
- "parameters": [
- {
- "name": "product_id",
- "in": "path",
- "required": True,
- "schema": {"type": "string"},
- }
- ],
- "responses": {"200": {"description": "A product"}},
- }
- },
- },
- }
-
- @pytest.fixture
- async def mock_30_client(self) -> httpx.AsyncClient:
- """Mock client that returns predefined responses for the 3.0 API."""
-
- async def _responder(request):
- if request.url.path == "/products" and request.method == "GET":
- return httpx.Response(
- 200,
- json=[
- {"id": "p1", "name": "Product 1", "price": 19.99},
- {"id": "p2", "name": "Product 2", "price": 29.99},
- ],
- )
- elif request.url.path == "/products" and request.method == "POST":
- data = json.loads(request.content)
- return httpx.Response(
- 201, json={"id": "p3", "name": data["name"], "price": data["price"]}
- )
- elif request.url.path.startswith("/products/") and request.method == "GET":
- product_id = request.url.path.split("/")[-1]
- products = {
- "p1": {"id": "p1", "name": "Product 1", "price": 19.99},
- "p2": {"id": "p2", "name": "Product 2", "price": 29.99},
- }
- if product_id in products:
- return httpx.Response(200, json=products[product_id])
- return httpx.Response(404, json={"error": "Product not found"})
- return httpx.Response(404)
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- @pytest.fixture
- async def openapi_30_server_with_all_types(
- self, openapi_30_spec, mock_30_client
- ) -> FastMCPOpenAPI:
- """Create a FastMCPOpenAPI server from the OpenAPI 3.0 spec."""
- return FastMCPOpenAPI(
- openapi_spec=openapi_30_spec,
- client=mock_30_client,
- name="Product API 3.0",
- route_maps=GET_ROUTE_MAPS,
- )
-
- async def test_server_creation(self, openapi_30_server_with_all_types):
- """Test that a server can be created from an OpenAPI 3.0 spec."""
- assert isinstance(openapi_30_server_with_all_types, FastMCP)
- assert openapi_30_server_with_all_types.name == "Product API 3.0"
-
- async def test_resource_discovery(self, openapi_30_server_with_all_types):
- """Test that resources are correctly discovered from an OpenAPI 3.0 spec."""
- async with Client(openapi_30_server_with_all_types) as client:
- resources = await client.list_resources()
- assert len(resources) == 1
- assert resources[0].uri == AnyUrl("resource://listProducts")
-
- async def test_resource_template_discovery(self, openapi_30_server_with_all_types):
- """Test that resource templates are correctly discovered from an OpenAPI 3.0 spec."""
- async with Client(openapi_30_server_with_all_types) as client:
- templates = await client.list_resource_templates()
- assert len(templates) == 1
- assert templates[0].name == "getProduct"
- assert templates[0].uriTemplate == r"resource://getProduct/{product_id}"
-
- async def test_tool_discovery(self, openapi_30_server_with_all_types):
- """Test that tools are correctly discovered from an OpenAPI 3.0 spec."""
- async with Client(openapi_30_server_with_all_types) as client:
- tools = await client.list_tools()
- assert len(tools) == 1
- assert tools[0].name == "createProduct"
- assert "name" in tools[0].inputSchema["properties"]
- assert "price" in tools[0].inputSchema["properties"]
-
- async def test_resource_access(self, openapi_30_server_with_all_types):
- """Test reading a resource from an OpenAPI 3.0 server."""
- async with Client(openapi_30_server_with_all_types) as client:
- resource_response = await client.read_resource("resource://listProducts")
- response_text = resource_response[0].text # type: ignore[attr-defined]
- content = json.loads(response_text)
- assert len(content) == 2
- assert content[0]["name"] == "Product 1"
- assert content[1]["name"] == "Product 2"
-
- async def test_resource_template_access(self, openapi_30_server_with_all_types):
- """Test reading a resource from template from an OpenAPI 3.0 server."""
- async with Client(openapi_30_server_with_all_types) as client:
- resource_response = await client.read_resource("resource://getProduct/p1")
- response_text = resource_response[0].text # type: ignore[attr-defined]
- content = json.loads(response_text)
- assert content["id"] == "p1"
- assert content["name"] == "Product 1"
- assert content["price"] == 19.99
-
- async def test_tool_execution(self, openapi_30_server_with_all_types):
- """Test executing a tool from an OpenAPI 3.0 server."""
- async with Client(openapi_30_server_with_all_types) as client:
- result = await client.call_tool(
- "createProduct", {"name": "New Product", "price": 39.99}
- )
- # Result should be a text content
- assert len(result.content) == 1
- product = json.loads(result.content[0].text) # type: ignore[attr-defined]
- assert product["id"] == "p3"
- assert product["name"] == "New Product"
- assert product["price"] == 39.99
-
- assert result.structured_content is not None
- assert result.structured_content["id"] == "p3"
- assert result.structured_content["name"] == "New Product"
- assert result.structured_content["price"] == 39.99
-
- assert result.data is not None
- assert result.data["id"] == "p3"
- assert result.data["name"] == "New Product"
- assert result.data["price"] == 39.99
-
-
-class TestOpenAPI31Compatibility:
- """Tests for compatibility with OpenAPI 3.1 specifications."""
-
- @pytest.fixture
- def openapi_31_spec(self) -> dict:
- """Fixture that returns a simple OpenAPI 3.1 specification."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Order API (3.1)", "version": "1.0.0"},
- "paths": {
- "/orders": {
- "get": {
- "operationId": "listOrders",
- "summary": "List all orders",
- "responses": {"200": {"description": "A list of orders"}},
- },
- "post": {
- "operationId": "createOrder",
- "summary": "Place a new order",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "customer": {"type": "string"},
- "items": {
- "type": "array",
- "items": {"type": "string"},
- },
- },
- "required": ["customer", "items"],
- }
- }
- },
- },
- "responses": {"201": {"description": "Order created"}},
- },
- },
- "/orders/{order_id}": {
- "get": {
- "operationId": "getOrder",
- "summary": "Get order by ID",
- "parameters": [
- {
- "name": "order_id",
- "in": "path",
- "required": True,
- "schema": {"type": "string"},
- }
- ],
- "responses": {"200": {"description": "An order"}},
- }
- },
- },
- }
-
- @pytest.fixture
- async def mock_31_client(self) -> httpx.AsyncClient:
- """Mock client that returns predefined responses for the 3.1 API."""
-
- async def _responder(request):
- if request.url.path == "/orders" and request.method == "GET":
- return httpx.Response(
- 200,
- json=[
- {"id": "o1", "customer": "Alice", "items": ["item1", "item2"]},
- {"id": "o2", "customer": "Bob", "items": ["item3"]},
- ],
- )
- elif request.url.path == "/orders" and request.method == "POST":
- data = json.loads(request.content)
- return httpx.Response(
- 201,
- json={
- "id": "o3",
- "customer": data["customer"],
- "items": data["items"],
- },
- )
- elif request.url.path.startswith("/orders/") and request.method == "GET":
- order_id = request.url.path.split("/")[-1]
- orders = {
- "o1": {
- "id": "o1",
- "customer": "Alice",
- "items": ["item1", "item2"],
- },
- "o2": {"id": "o2", "customer": "Bob", "items": ["item3"]},
- }
- if order_id in orders:
- return httpx.Response(200, json=orders[order_id])
- return httpx.Response(404, json={"error": "Order not found"})
- return httpx.Response(404)
-
- transport = httpx.MockTransport(_responder)
- return httpx.AsyncClient(transport=transport, base_url="http://test")
-
- @pytest.fixture
- async def openapi_31_server_with_all_types(
- self, openapi_31_spec, mock_31_client
- ) -> FastMCPOpenAPI:
- """Create a FastMCPOpenAPI server from the OpenAPI 3.1 spec."""
- return FastMCPOpenAPI(
- openapi_spec=openapi_31_spec,
- client=mock_31_client,
- name="Order API 3.1",
- route_maps=GET_ROUTE_MAPS,
- )
-
- async def test_server_creation(self, openapi_31_server_with_all_types):
- """Test that a server can be created from an OpenAPI 3.1 spec."""
- assert isinstance(openapi_31_server_with_all_types, FastMCP)
- assert openapi_31_server_with_all_types.name == "Order API 3.1"
-
- async def test_resource_discovery(self, openapi_31_server_with_all_types):
- """Test that resources are correctly discovered from an OpenAPI 3.1 spec."""
- async with Client(openapi_31_server_with_all_types) as client:
- resources = await client.list_resources()
- assert len(resources) == 1
- assert resources[0].uri == AnyUrl("resource://listOrders")
-
- async def test_resource_template_discovery(self, openapi_31_server_with_all_types):
- """Test that resource templates are correctly discovered from an OpenAPI 3.1 spec."""
- async with Client(openapi_31_server_with_all_types) as client:
- templates = await client.list_resource_templates()
- assert len(templates) == 1
- assert templates[0].name == "getOrder"
- assert templates[0].uriTemplate == r"resource://getOrder/{order_id}"
-
- async def test_tool_discovery(self, openapi_31_server_with_all_types):
- """Test that tools are correctly discovered from an OpenAPI 3.1 spec."""
- async with Client(openapi_31_server_with_all_types) as client:
- tools = await client.list_tools()
- assert len(tools) == 1
- assert tools[0].name == "createOrder"
- assert "customer" in tools[0].inputSchema["properties"]
- assert "items" in tools[0].inputSchema["properties"]
-
- async def test_resource_access(self, openapi_31_server_with_all_types):
- """Test reading a resource from an OpenAPI 3.1 server."""
- async with Client(openapi_31_server_with_all_types) as client:
- resource_response = await client.read_resource("resource://listOrders")
- response_text = resource_response[0].text # type: ignore[attr-defined]
- content = json.loads(response_text)
- assert len(content) == 2
- assert content[0]["customer"] == "Alice"
- assert content[1]["customer"] == "Bob"
-
- async def test_resource_template_access(self, openapi_31_server_with_all_types):
- """Test reading a resource from template from an OpenAPI 3.1 server."""
- async with Client(openapi_31_server_with_all_types) as client:
- resource_response = await client.read_resource("resource://getOrder/o1")
- response_text = resource_response[0].text # type: ignore[attr-defined]
- content = json.loads(response_text)
- assert content["id"] == "o1"
- assert content["customer"] == "Alice"
- assert content["items"] == ["item1", "item2"]
-
- async def test_tool_execution(self, openapi_31_server_with_all_types):
- """Test executing a tool from an OpenAPI 3.1 server."""
- async with Client(openapi_31_server_with_all_types) as client:
- result = await client.call_tool(
- "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
- )
- # Result should be a text content
- assert len(result.content) == 1
- order = json.loads(result.content[0].text) # type: ignore[attr-defined]
- assert order["id"] == "o3"
- assert order["customer"] == "Charlie"
- assert order["items"] == ["item4", "item5"]
-
- assert result.structured_content is not None
- assert result.structured_content["id"] == "o3"
- assert result.structured_content["customer"] == "Charlie"
- assert result.structured_content["items"] == ["item4", "item5"]
-
- assert result.data is not None
- assert result.data["id"] == "o3"
- assert result.data["customer"] == "Charlie"
- assert result.data["items"] == ["item4", "item5"]
-
-
-class TestOpenAPIVersionDifferences:
- """Test specific differences between OpenAPI 3.0 and 3.1 that can cause compatibility issues."""
-
- def test_openapi_30_exclusive_maximum_boolean_format(self):
- """Test OpenAPI 3.0 format with boolean exclusiveMaximum (reproduces GitHub issue #1021)."""
- spec_with_exclusive_max = {
- "openapi": "3.0.0",
- "info": {"title": "Loan API", "version": "1.0.0"},
- "paths": {
- "/loans": {
- "post": {
- "operationId": "createLoan",
- "summary": "Create a loan",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/LoanDetails"
- }
- }
- },
- },
- "responses": {"201": {"description": "Loan created"}},
- }
- }
- },
- "components": {
- "schemas": {
- "LoanDetails": {
- "type": "object",
- "properties": {
- "amount": {"type": "number", "minimum": 0},
- "interest_rate": {
- "type": "number",
- "minimum": 0,
- "maximum": 100,
- "exclusiveMaximum": True, # OpenAPI 3.0 boolean format
- },
- },
- "required": ["amount", "interest_rate"],
- }
- }
- },
- }
-
- # This should not raise a ValidationError
- routes = parse_openapi_to_http_routes(spec_with_exclusive_max)
- assert len(routes) == 1
- assert routes[0].operation_id == "createLoan"
-
- def test_openapi_31_exclusive_maximum_numeric_format(self):
- """Test OpenAPI 3.1 format with numeric exclusiveMaximum."""
- spec_with_exclusive_max = {
- "openapi": "3.1.0",
- "info": {"title": "Loan API", "version": "1.0.0"},
- "paths": {
- "/loans": {
- "post": {
- "operationId": "createLoan",
- "summary": "Create a loan",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/LoanDetails"
- }
- }
- },
- },
- "responses": {"201": {"description": "Loan created"}},
- }
- }
- },
- "components": {
- "schemas": {
- "LoanDetails": {
- "type": "object",
- "properties": {
- "amount": {"type": "number", "minimum": 0},
- "interest_rate": {
- "type": "number",
- "minimum": 0,
- "exclusiveMaximum": 100, # OpenAPI 3.1 numeric format
- },
- },
- "required": ["amount", "interest_rate"],
- }
- }
- },
- }
-
- # This should not raise a ValidationError
- routes = parse_openapi_to_http_routes(spec_with_exclusive_max)
- assert len(routes) == 1
- assert routes[0].operation_id == "createLoan"
-
- def test_openapi_30_nullable_format(self):
- """Test OpenAPI 3.0 nullable format."""
- spec_with_nullable = {
- "openapi": "3.0.0",
- "info": {"title": "User API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "post": {
- "operationId": "createUser",
- "summary": "Create a user",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "email": {
- "type": "string",
- "nullable": True, # OpenAPI 3.0 nullable format
- },
- },
- "required": ["name"],
- }
- }
- },
- },
- "responses": {"201": {"description": "User created"}},
- }
- }
- },
- }
-
- # This should not raise a ValidationError
- routes = parse_openapi_to_http_routes(spec_with_nullable)
- assert len(routes) == 1
- assert routes[0].operation_id == "createUser"
-
- def test_openapi_31_type_array_format(self):
- """Test OpenAPI 3.1 type array format for nullable values."""
- spec_with_type_array = {
- "openapi": "3.1.0",
- "info": {"title": "User API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "post": {
- "operationId": "createUser",
- "summary": "Create a user",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "email": {
- "type": [
- "string",
- "null",
- ], # OpenAPI 3.1 type array format
- },
- },
- "required": ["name"],
- }
- }
- },
- },
- "responses": {"201": {"description": "User created"}},
- }
- }
- },
- }
-
- # This should not raise a ValidationError
- routes = parse_openapi_to_http_routes(spec_with_type_array)
- assert len(routes) == 1
- assert routes[0].operation_id == "createUser"
-
- def test_openapi_30_with_defs_and_exclusive_maximum(self):
- """Test OpenAPI 3.0 with $defs and exclusiveMaximum (complex case from GitHub issue #1021)."""
- spec_with_defs = {
- "openapi": "3.0.0",
- "info": {"title": "Complex Loan API", "version": "1.0.0"},
- "paths": {
- "/loans": {
- "post": {
- "operationId": "createComplexLoan",
- "summary": "Create a complex loan",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "loanDetails": {
- "$ref": "#/components/schemas/LoanDetails"
- }
- },
- "required": ["loanDetails"],
- "$defs": {
- "LoanDetails": {
- "type": "object",
- "properties": {
- "interest_rate": {
- "type": "number",
- "minimum": 0,
- "maximum": 100,
- "exclusiveMaximum": True, # This should trigger the issue
- },
- },
- "required": ["interest_rate"],
- }
- },
- }
- }
- },
- },
- "responses": {"201": {"description": "Complex loan created"}},
- }
- }
- },
- "components": {
- "schemas": {
- "LoanDetails": {
- "type": "object",
- "properties": {
- "interest_rate": {
- "type": "number",
- "minimum": 0,
- "maximum": 100,
- "exclusiveMaximum": True,
- },
- },
- "required": ["interest_rate"],
- }
- }
- },
- }
-
- # This should not raise a ValidationError (GitHub issue #1021 should be fixed)
- routes = parse_openapi_to_http_routes(spec_with_defs)
- assert len(routes) == 1
- assert routes[0].operation_id == "createComplexLoan"
-
- def test_openapi_30_edge_case_with_multiple_exclusive_constraints(self):
- """Test edge case with multiple exclusive constraints that might trigger validation issues."""
- spec_edge_case = {
- "openapi": "3.0.0",
- "info": {"title": "Edge Case API", "version": "1.0.0"},
- "paths": {
- "/validate": {
- "post": {
- "operationId": "validateData",
- "summary": "Validate data with edge case constraints",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "percentage": {
- "type": "number",
- "minimum": 0,
- "maximum": 100,
- "exclusiveMaximum": True,
- "exclusiveMinimum": True, # Both exclusive constraints
- },
- "rating": {
- "type": "integer",
- "minimum": 1,
- "maximum": 10,
- "exclusiveMaximum": True,
- },
- },
- "required": ["percentage", "rating"],
- }
- }
- },
- },
- "responses": {"200": {"description": "Data validated"}},
- }
- }
- },
- }
-
- # This might trigger validation issues with multiple exclusive constraints
- routes = parse_openapi_to_http_routes(spec_edge_case)
- assert len(routes) == 1
- assert routes[0].operation_id == "validateData"
diff --git a/tests/experimental/openapi_parser/server/openapi/test_openapi_features.py b/tests/server/openapi/test_openapi_features.py
similarity index 99%
rename from tests/experimental/openapi_parser/server/openapi/test_openapi_features.py
rename to tests/server/openapi/test_openapi_features.py
index 2dfac618e..747906bf9 100644
--- a/tests/experimental/openapi_parser/server/openapi/test_openapi_features.py
+++ b/tests/server/openapi/test_openapi_features.py
@@ -4,7 +4,7 @@ import httpx
import pytest
from fastmcp.client import Client
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
+from fastmcp.server.openapi import FastMCPOpenAPI
class TestParameterHandling:
diff --git a/tests/server/openapi/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py
deleted file mode 100644
index db782b938..000000000
--- a/tests/server/openapi/test_openapi_path_parameters.py
+++ /dev/null
@@ -1,624 +0,0 @@
-from typing import Annotated, Literal
-from unittest.mock import AsyncMock, MagicMock
-
-import httpx
-import pytest
-from fastapi import FastAPI, Query
-
-from fastmcp import Client, FastMCP
-from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap
-from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo
-
-
-@pytest.fixture
-def array_path_spec():
- """Load a minimal OpenAPI spec with an array path parameter."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/select/{days}": {
- "put": {
- "operationId": "test-operation",
- "parameters": [
- {
- "name": "days",
- "in": "path",
- "required": True,
- "style": "simple",
- "explode": False,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
- ],
- },
- },
- }
- ],
- "responses": {
- "200": {
- "description": "Success",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {"result": {"type": "string"}},
- "required": ["result"],
- }
- }
- },
- }
- },
- }
- }
- },
- }
-
-
-@pytest.fixture
-def mock_client():
- """Create a mock httpx.AsyncClient."""
- client = AsyncMock(spec=httpx.AsyncClient)
- # Set up a mock response
- mock_response = MagicMock()
- mock_response.json.return_value = {"result": "success"}
- mock_response.raise_for_status.return_value = None
- client.request.return_value = mock_response
- return client
-
-
-async def test_fastmcp_from_openapi(array_path_spec, mock_client):
- """Test creating FastMCP from OpenAPI spec with array path parameter."""
- # Create FastMCP from the spec
- mcp = FastMCP.from_openapi(array_path_spec, client=mock_client)
-
- # Verify the tool was created using the MCP protocol method
- tools_result = await mcp.get_tools()
- tool_names = [tool.name for tool in tools_result.values()]
- assert "test_operation" in tool_names
-
-
-async def test_array_path_parameter_handling(mock_client):
- """Test how array path parameters are handled."""
- # Create a simple route with array path parameter
- route = HTTPRoute(
- path="/select/{days}",
- method="PUT",
- operation_id="test_operation",
- parameters=[
- ParameterInfo(
- name="days",
- location="path",
- required=True,
- schema={
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
- ],
- },
- },
- )
- ],
- )
-
- # Create the tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="test_operation",
- description="Test operation",
- parameters={},
- )
-
- # Test with a single value
- await tool.run({"days": ["monday"]})
-
- # Check that the path parameter is formatted correctly
- # This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']'
- mock_client.request.assert_called_with(
- method="PUT",
- url="/select/monday", # This is the expected format
- params={},
- headers={},
- json=None,
- timeout=None,
- )
- mock_client.request.reset_mock()
-
- # Test with multiple values
- await tool.run({"days": ["monday", "tuesday"]})
-
- # Check that the path parameter is formatted correctly
- # It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']'
- mock_client.request.assert_called_with(
- method="PUT",
- url="/select/monday,tuesday", # This is the expected format
- params={},
- headers={},
- json=None,
- timeout=None,
- )
-
-
-async def test_integration_array_path_parameter(array_path_spec, mock_client):
- """Integration test for array path parameters."""
- # Create FastMCP from the spec
- mcp = FastMCP.from_openapi(array_path_spec, client=mock_client)
-
- # Call the tool with a single value
- await mcp._call_tool_mcp("test_operation", {"days": ["monday"]})
-
- # Check the request was made correctly
- mock_client.request.assert_called_with(
- method="PUT",
- url="/select/monday",
- params={},
- headers={},
- json=None,
- timeout=None,
- )
- mock_client.request.reset_mock()
-
- # Call the tool with multiple values
- await mcp._call_tool_mcp("test_operation", {"days": ["monday", "tuesday"]})
-
- # Check the request was made correctly
- mock_client.request.assert_called_with(
- method="PUT",
- url="/select/monday,tuesday",
- params={},
- headers={},
- json=None,
- timeout=None,
- )
-
-
-async def test_complex_nested_array_path_parameter(mock_client):
- """Test handling of complex nested array path parameters."""
- # Create a route with a path parameter that contains nested objects in an array
- route = HTTPRoute(
- path="/report/{filters}",
- method="GET",
- operation_id="test-complex-filters",
- parameters=[
- ParameterInfo(
- name="filters",
- location="path",
- required=True,
- schema={
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "field": {"type": "string"},
- "value": {"type": "string"},
- },
- },
- },
- )
- ],
- )
-
- # Create the tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="test-complex-filters",
- description="Test operation with complex filters",
- parameters={},
- )
-
- # Test with a more complex path parameter
- # This would typically be serialized as JSON or a more complex format
- # But for path parameters with style=simple, it should be comma-separated
- complex_filters = [
- {"field": "status", "value": "active"},
- {"field": "type", "value": "user"},
- ]
-
- # Execute the request with complex filters
- await tool.run({"filters": complex_filters})
-
- # The complex object should be properly serialized in the URL
- # For path parameters, this would typically need a custom serialization strategy
- # but our implementation should handle it safely
- call_args = mock_client.request.call_args
-
- # Verify the request was made
- assert call_args is not None, "The request was not made"
-
- # Get the called URL and verify it contains the serialized path parameter
- called_url = call_args[1].get("url")
-
- # Check that the path parameter is handled (we don't expect perfect serialization,
- # but it should not cause errors and should maintain the array structure)
- assert "/report/" in called_url, "The URL should contain the path prefix"
-
- # Check that it didn't just convert the objects to string representations
- # that include the Python object syntax
- assert "status" in called_url, "The URL should contain filter field names"
- assert "active" in called_url, "The URL should contain filter values"
- assert "}" not in called_url, "The URL should not contain Python object syntax"
- assert "{" not in called_url, "The URL should not contain Python object syntax"
-
-
-async def test_array_query_param_with_fastapi():
- """Test array query parameters using FastAPI and FastMCP.from_fastapi integration."""
- # Create a FastAPI app with a route that has an array query parameter
- app = FastAPI()
-
- @app.get("/select")
- async def select_days(
- days: Annotated[
- list[
- Literal[
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
- ]
- ],
- Query(explode=True),
- ],
- ): # Using explode=True to get days=monday&days=tuesday format
- return {"selected": days}
-
- # Create a FastMCP server from the FastAPI app
- mcp = FastMCP.from_fastapi(
- app,
- route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
- )
-
- # Test with the client
- async with Client(mcp) as client:
- # Get the actual tool name first
- tools = await client.list_tools()
- tool_names = [tool.name for tool in tools]
- assert len(tool_names) == 1, (
- f"Expected one tool, got {len(tool_names)}: {tool_names}"
- )
- tool_name = tool_names[0]
-
- # Single day
- result = await client.call_tool(tool_name, {"days": ["monday"]})
- assert result.data == {"selected": ["monday"]}
-
- # Multiple days
- result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
- assert result.data == {"selected": ["monday", "tuesday"]}
-
-
-async def test_array_query_parameter_format(mock_client):
- """Test that array query parameters are formatted as comma-separated values when explode=False."""
- # Create a route with array query parameter
- route = HTTPRoute(
- path="/select",
- method="GET",
- operation_id="test-operation",
- parameters=[
- ParameterInfo(
- name="days",
- location="query", # This is a query parameter
- required=True,
- explode=False, # Set explode=False to test comma-separated formatting
- schema={
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
- ],
- },
- },
- )
- ],
- )
-
- # Create the tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="test-operation",
- description="Test operation",
- parameters={},
- )
-
- # Test with a single value
- await tool.run({"days": ["monday"]})
-
- # Check that the query parameter is formatted correctly
- mock_client.request.assert_called_with(
- method="GET",
- url="/select",
- params={"days": "monday"}, # Should be formatted as a string, not a list
- headers={},
- json=None,
- timeout=None,
- )
- mock_client.request.reset_mock()
-
- # Test with multiple values
- await tool.run({"days": ["monday", "tuesday"]})
-
- # Check that the query parameter is formatted correctly
- # It should be 'days=monday,tuesday' not 'days=["monday","tuesday"]'
- mock_client.request.assert_called_with(
- method="GET",
- url="/select",
- params={"days": "monday,tuesday"}, # Should be comma-separated
- headers={},
- json=None,
- timeout=None,
- )
-
-
-async def test_array_query_parameter_exploded_format(mock_client):
- """Test that array query parameters are formatted as separate parameters when explode=True."""
- # Create a route with array query parameter with explode=True (default)
- route = HTTPRoute(
- path="/select-exploded",
- method="GET",
- operation_id="test-exploded-operation",
- parameters=[
- ParameterInfo(
- name="days",
- location="query", # This is a query parameter
- required=True,
- explode=True, # Set explode=True for separate parameter serialization
- schema={
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
- ],
- },
- },
- )
- ],
- )
-
- # Create the tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="test-exploded-operation",
- description="Test operation with exploded arrays",
- parameters={},
- )
-
- # Test with a single value
- await tool.run({"days": ["monday"]})
-
- # Check that the query parameter is formatted correctly
- mock_client.request.assert_called_with(
- method="GET",
- url="/select-exploded",
- params={"days": ["monday"]}, # Should be passed as a list for explode=True
- headers={},
- json=None,
- timeout=None,
- )
- mock_client.request.reset_mock()
-
- # Test with multiple values
- await tool.run({"days": ["monday", "tuesday"]})
-
- # Check that the query parameter is formatted correctly
- # It should be passed as an array, which httpx will serialize as days=monday&days=tuesday
- mock_client.request.assert_called_with(
- method="GET",
- url="/select-exploded",
- params={"days": ["monday", "tuesday"]}, # Should be passed as a list
- headers={},
- json=None,
- timeout=None,
- )
-
-
-async def test_empty_array_parameter_exclusion(mock_client):
- """Test that empty array parameters are excluded from requests."""
- # Create a route with array query parameter
- route = HTTPRoute(
- path="/search",
- method="GET",
- operation_id="search-operation",
- parameters=[
- ParameterInfo(
- name="tags",
- location="query",
- required=False,
- schema={
- "type": "array",
- "items": {"type": "string"},
- },
- ),
- ParameterInfo(
- name="categories",
- location="query",
- required=False,
- schema={
- "type": "array",
- "items": {"type": "string"},
- },
- ),
- ParameterInfo(
- name="limit",
- location="query",
- required=False,
- schema={"type": "integer"},
- ),
- ],
- )
-
- # Create the tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="search-operation",
- description="Search operation",
- parameters={},
- )
-
- # Test with empty array - should be excluded
- await tool.run(
- {
- "tags": [], # Empty array should be excluded
- "categories": ["tech", "news"], # Non-empty array should be included
- "limit": 10, # Non-array param should be included
- }
- )
-
- # Check that empty array is excluded, but others are included
- mock_client.request.assert_called_with(
- method="GET",
- url="/search",
- params={
- "categories": ["tech", "news"], # Only non-empty array included
- "limit": 10,
- },
- headers={},
- json=None,
- timeout=None,
- )
-
-
-async def test_empty_deep_object_parameter_exclusion(mock_client):
- """Test that empty dict parameters with deepObject style are excluded from requests."""
- # Create a route with deepObject query parameter
- route = HTTPRoute(
- path="/filter",
- method="GET",
- operation_id="filter-operation",
- parameters=[
- ParameterInfo(
- name="filters",
- location="query",
- required=False,
- style="deepObject",
- explode=True,
- schema={
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "age": {"type": "integer"},
- },
- },
- ),
- ParameterInfo(
- name="options",
- location="query",
- required=False,
- style="deepObject",
- explode=True,
- schema={
- "type": "object",
- "properties": {
- "sort": {"type": "string"},
- "order": {"type": "string"},
- },
- },
- ),
- ParameterInfo(
- name="page",
- location="query",
- required=False,
- schema={"type": "integer"},
- ),
- ],
- )
-
- # Create the tool
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="filter-operation",
- description="Filter operation",
- parameters={},
- )
-
- # Test with empty dict - should be excluded
- await tool.run(
- {
- "filters": {}, # Empty dict should be excluded
- "options": {
- "sort": "name",
- "order": "asc",
- }, # Non-empty dict should be included
- "page": 1, # Non-dict param should be included
- }
- )
-
- # Check that empty dict is excluded, but others are included
- mock_client.request.assert_called_with(
- method="GET",
- url="/filter",
- params={
- "options[sort]": "name", # Deep object style for non-empty dict
- "options[order]": "asc",
- "page": 1,
- },
- headers={},
- json=None,
- timeout=None,
- )
-
-
-def test_parameter_location_enum_handling():
- """Test that ParameterLocation enum values are handled correctly (issue #950)."""
- from enum import Enum
-
- # Create a mock ParameterLocation enum like the one from openapi_pydantic
- class MockParameterLocation(Enum):
- PATH = "path"
- QUERY = "query"
- HEADER = "header"
- COOKIE = "cookie"
-
- # Test the enum handling logic directly (reproduces the fix in openapi.py)
- test_cases = [
- (MockParameterLocation.PATH, "path"),
- (MockParameterLocation.QUERY, "query"),
- (MockParameterLocation.HEADER, "header"),
- (MockParameterLocation.COOKIE, "cookie"),
- ("path", "path"), # Also test that strings work
- ("query", "query"),
- ]
-
- for param_in, expected_str in test_cases:
- # This is the enum handling logic from the fix
- param_in_str = param_in.value if isinstance(param_in, Enum) else param_in
- assert param_in_str == expected_str
- assert isinstance(param_in_str, str)
diff --git a/tests/experimental/openapi_parser/server/openapi/test_openapi_performance.py b/tests/server/openapi/test_openapi_performance.py
similarity index 96%
rename from tests/experimental/openapi_parser/server/openapi/test_openapi_performance.py
rename to tests/server/openapi/test_openapi_performance.py
index d62ffbbbc..017d55844 100644
--- a/tests/experimental/openapi_parser/server/openapi/test_openapi_performance.py
+++ b/tests/server/openapi/test_openapi_performance.py
@@ -10,13 +10,6 @@ import httpx
import pytest
from fastmcp import FastMCP
-from fastmcp.utilities.tests import temporary_settings
-
-
-@pytest.fixture(autouse=True)
-def use_new_openapi_parser():
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- yield
class TestOpenAPIPerformance:
diff --git a/tests/server/openapi/test_optional_parameters.py b/tests/server/openapi/test_optional_parameters.py
deleted file mode 100644
index 87ca5fc5d..000000000
--- a/tests/server/openapi/test_optional_parameters.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Test for optional parameter handling in FastMCP OpenAPI integration."""
-
-import pytest
-
-from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo, _combine_schemas
-
-
-async def test_optional_parameter_schema_preserves_original_type():
- """Test that optional parameters preserve their original schema without forcing nullable behavior."""
- # Create a minimal HTTPRoute with optional parameter
- optional_param = ParameterInfo(
- name="optional_param",
- location="query",
- required=False,
- schema={"type": "string"},
- description="Optional parameter",
- )
-
- required_param = ParameterInfo(
- name="required_param",
- location="query",
- required=True,
- schema={"type": "string"},
- description="Required parameter",
- )
-
- route = HTTPRoute(
- method="GET",
- path="/test",
- parameters=[required_param, optional_param],
- request_body=None,
- responses={},
- summary="Test endpoint",
- description=None,
- schema_definitions={},
- )
-
- # Generate combined schema
- schema = _combine_schemas(route)
-
- # Verify that optional parameter preserves original schema
- optional_param_schema = schema["properties"]["optional_param"]
-
- # Should preserve the original type without making it nullable
- assert optional_param_schema["type"] == "string"
- assert "anyOf" not in optional_param_schema
-
- # Required parameter should not allow null
- required_param_schema = schema["properties"]["required_param"]
- assert required_param_schema["type"] == "string"
- assert "anyOf" not in required_param_schema
-
- # Required list should only contain required param
- assert "required_param" in schema["required"]
- assert "optional_param" not in schema["required"]
-
-
-@pytest.mark.parametrize(
- "param_schema",
- [
- {"type": "string"},
- {"type": "integer"},
- {"type": "number"},
- {"type": "boolean"},
- {"type": "array", "items": {"type": "string"}},
- {"type": "object", "properties": {"name": {"type": "string"}}},
- ],
-)
-async def test_optional_parameter_preserves_schema_for_all_types(param_schema):
- """Test that optional parameters of any type preserve their original schema without nullable behavior."""
- optional_param = ParameterInfo(
- name="optional_param",
- location="query",
- required=False,
- schema=param_schema,
- description="Optional parameter",
- )
-
- route = HTTPRoute(
- method="GET",
- path="/test",
- parameters=[optional_param],
- request_body=None,
- responses={},
- summary="Test endpoint",
- description=None,
- schema_definitions={},
- )
-
- # Generate combined schema
- schema = _combine_schemas(route)
- optional_param_schema = schema["properties"]["optional_param"]
-
- # Should preserve the original schema exactly without making it nullable
- assert "anyOf" not in optional_param_schema
-
- # The schema should include the original type and fields, plus the description
- for key, value in param_schema.items():
- assert optional_param_schema[key] == value
- assert optional_param_schema.get("description") == "Optional parameter"
diff --git a/tests/server/openapi/test_parameter_collisions.py b/tests/server/openapi/test_parameter_collisions.py
index 78dd363cb..80d616c66 100644
--- a/tests/server/openapi/test_parameter_collisions.py
+++ b/tests/server/openapi/test_parameter_collisions.py
@@ -1,258 +1,212 @@
-"""Tests for handling parameter name collisions between different OpenAPI parameter locations."""
-
-from unittest.mock import AsyncMock, MagicMock
+"""Tests for parameter collision handling in openapi_new."""
import httpx
import pytest
-from fastmcp.server.openapi import OpenAPITool
-from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo, RequestBodyInfo
-
-
-@pytest.fixture
-def mock_client():
- """Create a mock httpx.AsyncClient."""
- client = AsyncMock(spec=httpx.AsyncClient)
- mock_response = MagicMock()
- mock_response.json.return_value = {"result": "success"}
- mock_response.raise_for_status.return_value = None
- client.request.return_value = mock_response
- return client
+from fastmcp.client import Client
+from fastmcp.server.openapi import FastMCPOpenAPI
class TestParameterCollisions:
- """Test parameter name collisions between path/query/header and body parameters."""
+ """Test parameter name collisions between different locations (path, query, body)."""
- async def test_path_body_collision_current_broken_behavior(self, mock_client):
- """
- Demonstrates the current broken behavior when a parameter exists in both path and body.
- This test should FAIL with the current implementation.
- """
- # Create route with collision: id in both path and body
- route = HTTPRoute(
- path="/users/{id}",
- method="PUT",
- operation_id="update_user",
- parameters=[
- ParameterInfo(
- name="id",
- location="path",
- required=True,
- schema={"type": "integer"},
- )
- ],
- request_body=RequestBodyInfo(
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "id": {"type": "integer", "description": "User ID"},
- "name": {"type": "string", "description": "User name"},
- "email": {"type": "string", "description": "User email"},
- },
- "required": ["id", "name"],
- }
- }
- ),
- )
-
- # Create tool with current implementation
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="update_user",
- description="Update user",
- parameters={}, # Schema would be generated by _combine_schemas
- )
-
- # This call should work but currently fails because body 'id' is excluded
- arguments = {"id": 123, "name": "John Doe", "email": "john@example.com"}
-
- await tool.run(arguments)
-
- # Check what was actually sent
- call_args = mock_client.request.call_args
- assert call_args is not None
-
- # Current broken behavior: id goes to path but is excluded from body
- # This means the body is missing the required 'id' field
- assert call_args[1]["url"] == "/users/123" # Path parameter works
-
- # This assertion will FAIL with current implementation because 'id' is excluded from body
- expected_body = {"id": 123, "name": "John Doe", "email": "john@example.com"}
- assert call_args[1]["json"] == expected_body, (
- "Body should include 'id' parameter"
- )
-
- async def test_path_body_collision_with_suffixing(self, mock_client):
- """
- Test the desired behavior with parameter suffixing.
- This test should PASS after implementing the fix.
- """
- # Create route with collision: id in both path and body
- route = HTTPRoute(
- path="/users/{id}",
- method="PUT",
- operation_id="update_user",
- parameters=[
- ParameterInfo(
- name="id",
- location="path",
- required=True,
- schema={"type": "integer"},
- )
- ],
- request_body=RequestBodyInfo(
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "id": {"type": "integer", "description": "User ID"},
- "name": {"type": "string", "description": "User name"},
- "email": {"type": "string", "description": "User email"},
- },
- "required": ["id", "name"],
- }
- }
- ),
- )
-
- # Tool should be created with suffixed schema
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="update_user",
- description="Update user",
- parameters={}, # Schema would include id__path and id
- )
-
- # LLM would call with suffixed parameters
- arguments = {
- "id__path": 123, # Goes to path parameter
- "id": 123, # Goes to body parameter
- "name": "John Doe",
- "email": "john@example.com",
- }
-
- await tool.run(arguments)
-
- # Verify correct request was made
- call_args = mock_client.request.call_args
- assert call_args is not None
-
- # Path parameter should be populated from id__path
- assert call_args[1]["url"] == "/users/123"
-
- # Body should include id (from unsuffixed parameter)
- expected_body = {"id": 123, "name": "John Doe", "email": "john@example.com"}
- assert call_args[1]["json"] == expected_body
-
- async def test_query_body_collision_with_suffixing(self, mock_client):
- """Test parameter collision between query and body parameters."""
- route = HTTPRoute(
- path="/search",
- method="POST",
- operation_id="search_users",
- parameters=[
- ParameterInfo(
- name="limit",
- location="query",
- required=False,
- schema={"type": "integer", "default": 10},
- )
- ],
- request_body=RequestBodyInfo(
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "limit": {
- "type": "integer",
- "description": "Max results in response",
+ @pytest.fixture
+ def collision_spec(self):
+ """OpenAPI spec with parameter name collisions."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Collision Test API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/users/{id}": {
+ "put": {
+ "operationId": "update_user",
+ "summary": "Update user with collision between path and body",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ "description": "User ID in path",
+ }
+ ],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "User ID in body (different from path)",
+ },
+ "name": {
+ "type": "string",
+ "description": "User name",
+ },
+ "email": {
+ "type": "string",
+ "description": "User email",
+ },
+ },
+ "required": ["name", "email"],
+ }
+ }
},
- "query": {"type": "string", "description": "Search query"},
},
- "required": ["query"],
+ "responses": {
+ "200": {
+ "description": "User updated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ "email": {"type": "string"},
+ },
+ }
+ }
+ },
+ }
+ },
}
- }
- ),
- )
-
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="search_users",
- description="Search users",
- parameters={},
- )
-
- # LLM call with suffixed parameters
- arguments = {
- "limit__query": 5, # Goes to query parameter
- "limit": 100, # Goes to body parameter
- "query": "john",
+ },
+ "/search": {
+ "get": {
+ "operationId": "search_with_collision",
+ "summary": "Search with query and header collision",
+ "parameters": [
+ {
+ "name": "query",
+ "in": "query",
+ "required": True,
+ "schema": {"type": "string"},
+ "description": "Search query parameter",
+ },
+ {
+ "name": "query",
+ "in": "header",
+ "required": False,
+ "schema": {"type": "string"},
+ "description": "Search query in header",
+ },
+ ],
+ "responses": {
+ "200": {
+ "description": "Search results",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "results": {
+ "type": "array",
+ "items": {"type": "object"},
+ }
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ },
}
- await tool.run(arguments)
+ async def test_path_body_collision_handling(self, collision_spec):
+ """Test that path and body parameters with same name are handled correctly."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=collision_spec, client=client, name="Collision Test Server"
+ )
- call_args = mock_client.request.call_args
- assert call_args is not None
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
- # Query parameter from limit__query
- assert call_args[1]["params"] == {"limit": 5}
+ # Find the update user tool
+ update_tool = next(tool for tool in tools if tool.name == "update_user")
+ assert update_tool is not None
- # Body includes limit from unsuffixed parameter
- expected_body = {"limit": 100, "query": "john"}
- assert call_args[1]["json"] == expected_body
+ # Check that both path and body 'id' parameters are included
+ params = update_tool.inputSchema
+ properties = params["properties"]
- async def test_no_collisions_unchanged_behavior(self, mock_client):
- """Test that parameters with no collisions keep original names."""
- route = HTTPRoute(
- path="/users/{user_id}",
- method="POST",
- operation_id="create_user",
- parameters=[
- ParameterInfo(
- name="user_id",
- location="path",
- required=True,
- schema={"type": "integer"},
+ # Should have both path ID and body ID (with potential suffixing)
+ # The implementation should handle this collision by suffixing one of them
+ assert "id" in properties # One version of id
+
+ # Check for suffixed versions or verify both exist somehow
+ # The exact handling depends on implementation, but both should be accessible
+ param_names = list(properties.keys())
+ id_params = [name for name in param_names if "id" in name]
+ assert len(id_params) >= 1 # At least one id parameter
+
+ # Should also have other body parameters
+ assert "name" in properties
+ assert "email" in properties
+
+ # Required fields should include path parameter and required body fields
+ required = params.get("required", [])
+ assert "name" in required
+ assert "email" in required
+ # Path parameter should be required (may be suffixed)
+ id_required = any("id" in req for req in required)
+ assert id_required
+
+ async def test_query_header_collision_handling(self, collision_spec):
+ """Test that query and header parameters with same name are handled correctly."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=collision_spec, client=client, name="Collision Test Server"
+ )
+
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+
+ # Find the search tool
+ search_tool = next(
+ tool for tool in tools if tool.name == "search_with_collision"
)
- ],
- request_body=RequestBodyInfo(
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "email": {"type": "string"},
- },
- "required": ["name"],
- }
- }
- ),
- )
+ assert search_tool is not None
- tool = OpenAPITool(
- client=mock_client,
- route=route,
- name="create_user",
- description="Create user",
- parameters={},
- )
+ # Check that both query and header 'query' parameters are handled
+ params = search_tool.inputSchema
+ properties = params["properties"]
- # No collisions, so original parameter names should work
- arguments = {
- "user_id": 123, # Path parameter (no suffix needed)
- "name": "John", # Body parameter
- "email": "john@example.com",
- }
+ # Should handle the collision somehow (suffixing or other mechanism)
+ param_names = list(properties.keys())
+ query_params = [name for name in param_names if "query" in name]
+ assert len(query_params) >= 1 # At least one query parameter
- await tool.run(arguments)
+ # Required should include the required query parameter
+ required = params.get("required", [])
+ query_required = any("query" in req for req in required)
+ assert query_required
- call_args = mock_client.request.call_args
- assert call_args is not None
+ async def test_collision_resolution_maintains_functionality(self, collision_spec):
+ """Test that collision resolution doesn't break basic tool functionality."""
+ async with httpx.AsyncClient(base_url="https://api.example.com") as client:
+ server = FastMCPOpenAPI(
+ openapi_spec=collision_spec, client=client, name="Collision Test Server"
+ )
- assert call_args[1]["url"] == "/users/123"
- expected_body = {"name": "John", "email": "john@example.com"}
- assert call_args[1]["json"] == expected_body
+ async with Client(server) as mcp_client:
+ tools = await mcp_client.list_tools()
+
+ # Should successfully create tools despite collisions
+ assert len(tools) == 2
+
+ tool_names = {tool.name for tool in tools}
+ assert "update_user" in tool_names
+ assert "search_with_collision" in tool_names
+
+ # Tools should have valid schemas
+ for tool in tools:
+ assert tool.inputSchema is not None
+ assert tool.inputSchema["type"] == "object"
+ assert "properties" in tool.inputSchema
diff --git a/tests/experimental/openapi_parser/server/openapi/test_performance_comparison.py b/tests/server/openapi/test_performance_comparison.py
similarity index 99%
rename from tests/experimental/openapi_parser/server/openapi/test_performance_comparison.py
rename to tests/server/openapi/test_performance_comparison.py
index 808c3379f..77402aa48 100644
--- a/tests/experimental/openapi_parser/server/openapi/test_performance_comparison.py
+++ b/tests/server/openapi/test_performance_comparison.py
@@ -5,7 +5,7 @@ import time
import httpx
import pytest
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
+from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.openapi import FastMCPOpenAPI as LegacyFastMCPOpenAPI
diff --git a/tests/server/openapi/test_route_map_fn.py b/tests/server/openapi/test_route_map_fn.py
deleted file mode 100644
index 2689300ac..000000000
--- a/tests/server/openapi/test_route_map_fn.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Tests for the route_map_fn and component_fn functionality in FastMCPOpenAPI."""
-
-from unittest.mock import AsyncMock
-
-import httpx
-import pytest
-
-from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap, RouteMapFn
-
-
-@pytest.fixture
-def sample_openapi_spec():
- """Sample OpenAPI spec for testing."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "get": {
- "summary": "List users",
- "operationId": "listUsers",
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/users/{id}": {
- "get": {
- "summary": "Get user by ID",
- "operationId": "getUserById",
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": True,
- "schema": {"type": "string"},
- }
- ],
- "responses": {"200": {"description": "Success"}},
- }
- },
- "/admin/settings": {
- "get": {
- "summary": "Get admin settings",
- "operationId": "getAdminSettings",
- "responses": {"200": {"description": "Success"}},
- },
- "post": {
- "summary": "Update admin settings",
- "operationId": "updateAdminSettings",
- "requestBody": {
- "content": {"application/json": {"schema": {"type": "object"}}}
- },
- "responses": {"200": {"description": "Success"}},
- },
- },
- "/api/data": {
- "get": {
- "summary": "Get data",
- "operationId": "getData",
- "responses": {"200": {"description": "Success"}},
- }
- },
- },
- }
-
-
-@pytest.fixture
-def http_client():
- """HTTP client for testing."""
- return httpx.AsyncClient()
-
-
-def test_route_map_fn_none(sample_openapi_spec, http_client):
- """Test that server works correctly when route_map_fn is None."""
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_map_fn=None, # Explicitly set to None
- )
-
- assert server.name == "Test Server"
-
-
-def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client):
- """Test that route_map_fn can convert route types."""
-
- def admin_routes_to_tools(route, mcp_type):
- """Convert all admin routes to tools."""
- if "/admin/" in route.path:
- return MCPType.TOOL
- return None
-
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_map_fn=admin_routes_to_tools,
- )
-
- # Admin GET route should be converted to tool instead of resource
- tools = server._tool_manager._tools
- assert "getAdminSettings" in tools
-
- # Admin POST route should still be a tool (was already)
- assert "updateAdminSettings" in tools
-
-
-def test_component_fn_customization(sample_openapi_spec, http_client):
- """Test that component_fn can customize components."""
-
- def customize_components(route, component):
- """Customize components based on route."""
- from fastmcp.server.openapi import OpenAPIResource, OpenAPITool
-
- # Add custom tags to all components
- component.tags.add("custom")
-
- # Modify tool descriptions
- if isinstance(component, OpenAPITool):
- component.description = (component.description or "") + " [CUSTOMIZED TOOL]"
-
- # Modify resource descriptions
- if isinstance(component, OpenAPIResource):
- component.description = (
- component.description or ""
- ) + " [CUSTOMIZED RESOURCE]"
-
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- mcp_component_fn=customize_components,
- )
-
- # Check that components were customized
- tools = server._tool_manager._tools
- resources = server._resource_manager._resources
-
- # Tools should have custom tags and modified descriptions
- for tool in tools.values():
- assert "custom" in tool.tags
- assert "[CUSTOMIZED TOOL]" in (tool.description or "")
-
- # Resources should have custom tags and modified descriptions
- for resource in resources.values():
- assert "custom" in resource.tags
- assert "[CUSTOMIZED RESOURCE]" in (resource.description or "")
-
-
-def test_route_map_fn_returns_none(sample_openapi_spec, http_client):
- """Test that route_map_fn returning None uses defaults."""
-
- def always_return_none(route, mcp_type):
- """Always return None to use defaults."""
- return None
-
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_map_fn=always_return_none,
- )
-
- # Should have default behavior
- assert server.name == "Test Server"
- # Check that components were created with default mapping
- tools = server._tool_manager._tools
- resources = server._resource_manager._resources
- templates = server._resource_manager._templates
-
- # Should have tools, resources, and templates based on default mapping
- assert len(tools) > 0
- assert len(resources) == 0
- assert len(templates) == 0
-
-
-def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_client):
- """Test that route_map_fn is called for excluded routes and can rescue them."""
-
- # Exclude all admin routes
- route_maps = [
- RouteMap(
- methods=["GET", "POST"], pattern=r".*/admin/.*", mcp_type=MCPType.EXCLUDE
- )
- ]
-
- called_routes = []
-
- def track_calls_and_rescue(route, mcp_type):
- """Track which routes the function is called for and rescue some excluded routes."""
- called_routes.append((route.method, route.path))
-
- # Rescue the admin GET route by converting it to a tool
- if route.path == "/admin/settings" and route.method == "GET":
- return MCPType.TOOL
-
- return None # Accept the assignment for other routes
-
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_maps=route_maps,
- route_map_fn=track_calls_and_rescue,
- )
-
- # route_map_fn should now be called for all routes, including excluded admin routes
- assert ("GET", "/admin/settings") in called_routes
- assert ("GET", "/users") in called_routes
- assert ("GET", "/users/{id}") in called_routes
- assert ("GET", "/api/data") in called_routes
- assert ("POST", "/admin/settings") in called_routes
-
- # The rescued admin GET route should now be a tool
- tools = server._tool_manager._tools
- assert "getAdminSettings" in tools
-
- # The admin POST route should still be excluded (not rescued)
- assert "updateAdminSettings" not in tools
-
-
-def test_route_map_fn_error_handling(sample_openapi_spec, http_client):
- """Test that errors in route_map_fn are handled gracefully."""
-
- def error_function(route, mcp_type):
- """Function that raises an error."""
- if route.path == "/users":
- raise ValueError("Test error")
- return None
-
- # Should not raise an error, but log a warning
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_map_fn=error_function,
- )
-
- # Server should still be created successfully
- assert server.name == "Test Server"
-
-
-def test_component_fn_error_handling(sample_openapi_spec, http_client):
- """Test that errors in component_fn are handled gracefully."""
-
- def error_function(route, component):
- """Function that raises an error."""
- if route.path == "/users":
- raise ValueError("Test error in component_fn")
-
- # Should not raise an error, but log a warning
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- mcp_component_fn=error_function,
- )
-
- # Server should still be created successfully
- assert server.name == "Test Server"
-
-
-def test_combined_route_map_fn_and_component_fn(sample_openapi_spec, http_client):
- """Test using both route_map_fn and component_fn together."""
-
- def route_mapper(route, mcp_type):
- """Convert admin routes to tools."""
- if "/admin/" in route.path:
- return MCPType.TOOL
- return None
-
- def component_customizer(route, component):
- """Add admin tag to admin components."""
- if "/admin/" in route.path:
- component.tags.add("admin")
-
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_map_fn=route_mapper,
- mcp_component_fn=component_customizer,
- )
-
- # Check that both functions worked
- tools = server._tool_manager._tools
-
- # Admin GET route should be converted to tool
- assert "getAdminSettings" in tools
- admin_tool = tools["getAdminSettings"]
- assert "admin" in admin_tool.tags
-
- # Admin POST route should have admin tag
- admin_post_tool = tools["updateAdminSettings"]
- assert "admin" in admin_post_tool.tags
-
-
-def test_route_map_fn_signature_validation():
- """Test that route_map_fn has the correct signature."""
-
- from fastmcp.utilities import openapi
-
- # This is more of a type checking test
- def valid_route_map_fn(
- route: openapi.HTTPRoute, mcp_type: MCPType
- ) -> MCPType | None:
- return None
-
- # Should be assignable to RouteMapFn type
- fn: RouteMapFn = valid_route_map_fn
- assert callable(fn)
-
-
-def test_component_fn_signature_validation():
- """Test that component_fn has the correct signature."""
- from fastmcp.server.openapi import (
- ComponentFn,
- OpenAPIResource,
- OpenAPIResourceTemplate,
- OpenAPITool,
- )
- from fastmcp.utilities import openapi
-
- # This is more of a type checking test
- def valid_component_fn(
- route: openapi.HTTPRoute,
- component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate,
- ) -> None:
- pass
-
- # Should be assignable to ComponentFn type
- fn: ComponentFn = valid_component_fn
- assert callable(fn)
-
-
-def test_route_map_fn_can_rescue_excluded_routes(sample_openapi_spec, http_client):
- """Test that route_map_fn can rescue routes that were excluded by RouteMap."""
-
- # Exclude ALL routes by default
- route_maps = [
- RouteMap(mcp_type=MCPType.EXCLUDE) # Catch-all exclusion
- ]
-
- def rescue_users_routes(route, mcp_type):
- """Rescue only user-related routes."""
- if "/users" in route.path:
- # Rescue user routes as tools
- return MCPType.TOOL
- # Let everything else stay excluded
- return None
-
- server = FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=http_client,
- name="Test Server",
- route_maps=route_maps,
- route_map_fn=rescue_users_routes,
- )
-
- # Only user routes should be rescued as tools
- tools = server._tool_manager._tools
- resources = server._resource_manager._resources
- templates = server._resource_manager._templates
-
- # Should have user-related tools
- assert "listUsers" in tools
- assert "getUserById" in tools
-
- # Should have no resources or templates (everything excluded except rescued tools)
- assert len(resources) == 0
- assert len(templates) == 0
-
- # Admin and API routes should still be excluded
- assert "getAdminSettings" not in tools
- assert "updateAdminSettings" not in tools
- assert "getData" not in tools
-
-
-class TestComponentFnToolNameModificationBug:
- """Test that mcp_component_fn can modify tool names without breaking access (Issue #1091)."""
-
- @pytest.fixture
- def mocked_http_client(self):
- """Mock HTTP client that returns successful responses."""
- from unittest.mock import MagicMock
-
- mock_client = AsyncMock(spec=httpx.AsyncClient)
-
- # Mock a successful response
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.json.return_value = {"result": "success"}
- mock_response.raise_for_status.return_value = None
-
- mock_client.request.return_value = mock_response
- return mock_client
-
- @pytest.fixture
- def server_with_modified_tool_names(self, sample_openapi_spec, mocked_http_client):
- """Server with tool names modified by mcp_component_fn."""
-
- def modify_tool_names(route, component):
- """Modify tool names by adding v1_removed_ prefix."""
- from fastmcp.server.openapi import OpenAPITool
-
- if isinstance(component, OpenAPITool):
- if component.name.startswith("get"):
- component.name = "v1_removed_" + component.name
-
- return FastMCPOpenAPI(
- openapi_spec=sample_openapi_spec,
- client=mocked_http_client,
- name="Test Server",
- mcp_component_fn=modify_tool_names,
- )
-
- def test_registration(self, server_with_modified_tool_names):
- """Test that modified tool names are properly registered."""
- tools = server_with_modified_tool_names._tool_manager._tools
-
- # Tool should be registered with the modified name
- assert "v1_removed_getUserById" in tools
- assert "v1_removed_getAdminSettings" in tools
- assert "v1_removed_getData" in tools
-
- # The tool object should have the same name as the registration key
- for key, tool in tools.items():
- if key.startswith("v1_removed_"):
- assert tool.name == key
-
- async def test_client_access(self, server_with_modified_tool_names):
- """Test that modified tool names are accessible via client."""
- from fastmcp.client import Client
-
- async with Client(server_with_modified_tool_names) as client:
- # List tools to verify they are exposed correctly
- available_tools = await client.list_tools()
- tool_names = [tool.name for tool in available_tools]
-
- # Verify the modified tool names are available
- assert "v1_removed_getUserById" in tool_names
- assert "v1_removed_getAdminSettings" in tool_names
- assert "v1_removed_getData" in tool_names
-
- async def test_client_call(self, server_with_modified_tool_names):
- """Test that modified tool names can be called via client."""
- from fastmcp.client import Client
-
- async with Client(server_with_modified_tool_names) as client:
- # This should work without "Unknown tool" error
- result = await client.call_tool("v1_removed_getData", {})
- assert result.data == {"result": "success"}
diff --git a/tests/experimental/openapi_parser/server/openapi/test_server.py b/tests/server/openapi/test_server.py
similarity index 99%
rename from tests/experimental/openapi_parser/server/openapi/test_server.py
rename to tests/server/openapi/test_server.py
index 6767e4e69..5a0abfd5f 100644
--- a/tests/experimental/openapi_parser/server/openapi/test_server.py
+++ b/tests/server/openapi/test_server.py
@@ -4,7 +4,7 @@ import httpx
import pytest
from fastmcp.client import Client
-from fastmcp.experimental.server.openapi import FastMCPOpenAPI
+from fastmcp.server.openapi import FastMCPOpenAPI
class TestFastMCPOpenAPIBasicFunctionality:
diff --git a/tests/server/test_experimental_openapi_feature_flag.py b/tests/server/test_experimental_openapi_feature_flag.py
deleted file mode 100644
index 37ea3c372..000000000
--- a/tests/server/test_experimental_openapi_feature_flag.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""Test experimental OpenAPI parser feature flag behavior."""
-
-import httpx
-import pytest
-from fastapi import FastAPI
-
-from fastmcp import FastMCP
-from fastmcp.experimental.server.openapi import (
- FastMCPOpenAPI as ExperimentalFastMCPOpenAPI,
-)
-from fastmcp.server.openapi import FastMCPOpenAPI as LegacyFastMCPOpenAPI
-from fastmcp.utilities.tests import temporary_settings
-
-
-class TestOpenAPIExperimentalFeatureFlag:
- """Test experimental OpenAPI parser feature flag behavior."""
-
- @pytest.fixture
- def simple_openapi_spec(self):
- """Simple OpenAPI spec for testing."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/test": {
- "get": {
- "operationId": "test_operation",
- "summary": "Test operation",
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- @pytest.fixture
- def mock_client(self):
- """Mock HTTP client."""
- return httpx.AsyncClient(base_url="https://api.example.com")
-
- def test_from_openapi_uses_legacy_by_default(
- self, simple_openapi_spec, mock_client
- ):
- """Test that from_openapi uses legacy parser by default."""
- # Create server using from_openapi (should use legacy by default)
- server = FastMCP.from_openapi(
- openapi_spec=simple_openapi_spec, client=mock_client
- )
-
- # Should be the legacy implementation
- assert isinstance(server, LegacyFastMCPOpenAPI)
- # Note: Log message "Using legacy OpenAPI parser..." is emitted during creation
-
- def test_from_openapi_uses_experimental_with_flag(
- self, simple_openapi_spec, mock_client
- ):
- """Test that from_openapi uses experimental parser with flag enabled."""
- # Create server with experimental flag enabled
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- server = FastMCP.from_openapi(
- openapi_spec=simple_openapi_spec, client=mock_client
- )
-
- # Should be the experimental implementation
- assert isinstance(server, ExperimentalFastMCPOpenAPI)
- # Note: No log message should be emitted when using experimental parser
-
- def test_from_fastapi_uses_legacy_by_default(self):
- """Test that from_fastapi uses legacy parser by default."""
- # Create a simple FastAPI app
- app = FastAPI(title="Test API")
-
- @app.get("/test")
- def test_endpoint():
- return {"message": "test"}
-
- # Create server using from_fastapi (should use legacy by default)
- server = FastMCP.from_fastapi(app=app)
-
- # Should be the legacy implementation
- assert isinstance(server, LegacyFastMCPOpenAPI)
- # Note: Log message "Using legacy OpenAPI parser..." is emitted during creation
-
- def test_from_fastapi_uses_experimental_with_flag(self):
- """Test that from_fastapi uses experimental parser with flag enabled."""
- # Create a simple FastAPI app
- app = FastAPI(title="Test API")
-
- @app.get("/test")
- def test_endpoint():
- return {"message": "test"}
-
- # Create server with experimental flag enabled
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- server = FastMCP.from_fastapi(app=app)
-
- # Should be the experimental implementation
- assert isinstance(server, ExperimentalFastMCPOpenAPI)
- # Note: No log message should be emitted when using experimental parser
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index 22fcf5e59..ed95aa59e 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -1,24 +1,16 @@
-import logging
from pathlib import Path
from tempfile import TemporaryDirectory
from textwrap import dedent
-from typing import Annotated, Any
+from typing import Annotated
-import httpx
import pytest
-from fastapi import FastAPI
from mcp import McpError
from pydantic import Field
-from pytest import LogCaptureFixture
from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError
-from fastmcp.experimental.server.openapi import (
- FastMCPOpenAPI as ExperimentalFastMCPOpenAPI,
-)
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources import Resource, ResourceTemplate
-from fastmcp.server.openapi import FastMCPOpenAPI as LegacyFastMCPOpenAPI
from fastmcp.server.server import (
add_resource_prefix,
has_resource_prefix,
@@ -26,7 +18,6 @@ from fastmcp.server.server import (
)
from fastmcp.tools import FunctionTool
from fastmcp.tools.tool import Tool
-from fastmcp.utilities.tests import caplog_for_fastmcp, temporary_settings
class TestCreateServer:
@@ -1439,148 +1430,6 @@ class TestShouldIncludeComponent:
assert result is True
-class TestOpenAPIExperimentalFeatureFlag:
- """Test experimental OpenAPI parser feature flag behavior."""
-
- @pytest.fixture
- def simple_openapi_spec(self):
- """Simple OpenAPI spec for testing."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Test API", "version": "1.0.0"},
- "paths": {
- "/test": {
- "get": {
- "operationId": "test_operation",
- "summary": "Test operation",
- "responses": {"200": {"description": "Success"}},
- }
- }
- },
- }
-
- @pytest.fixture
- def mock_client(self):
- """Mock HTTP client."""
- return httpx.AsyncClient(base_url="https://api.example.com")
-
- def test_from_openapi_uses_legacy_by_default_and_logs_message(
- self,
- simple_openapi_spec: dict[str, Any],
- mock_client: httpx.AsyncClient,
- caplog: LogCaptureFixture,
- ):
- """Test that from_openapi uses legacy parser by default and emits log message."""
- # Capture all logs at INFO level and above using FastMCP's logger
- with caplog_for_fastmcp(caplog), caplog.at_level(logging.INFO):
- # Create server using from_openapi (should use legacy by default)
- server = FastMCP.from_openapi(
- openapi_spec=simple_openapi_spec, client=mock_client
- )
-
- # Should be the legacy implementation
- assert isinstance(server, LegacyFastMCPOpenAPI)
-
- # Should have logged the message about using legacy parser
- legacy_log_messages = [
- record
- for record in caplog.records
- if "Using legacy OpenAPI parser" in record.message
- ]
- assert len(legacy_log_messages) == 1
- assert legacy_log_messages[0].levelno == logging.INFO
- assert (
- "FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true"
- in legacy_log_messages[0].message
- )
-
- def test_from_openapi_uses_experimental_with_flag_and_no_log(
- self,
- simple_openapi_spec: dict[str, Any],
- mock_client: httpx.AsyncClient,
- caplog: LogCaptureFixture,
- ):
- """Test that from_openapi uses experimental parser with flag and emits no log."""
- # Capture all logs at INFO level and above
- with caplog.at_level(logging.INFO):
- # Create server with experimental flag enabled
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- server = FastMCP.from_openapi(
- openapi_spec=simple_openapi_spec, client=mock_client
- )
-
- # Should be the experimental implementation
- assert isinstance(server, ExperimentalFastMCPOpenAPI)
-
- # Should not have logged the legacy parser message
- legacy_log_messages = [
- record
- for record in caplog.records
- if "Using legacy OpenAPI parser" in record.message
- ]
- assert len(legacy_log_messages) == 0
-
- def test_from_fastapi_uses_legacy_by_default_and_logs_message(
- self, caplog: LogCaptureFixture
- ):
- """Test that from_fastapi uses legacy parser by default and emits log message."""
- # Capture all logs at INFO level and above using FastMCP's logger
- with caplog_for_fastmcp(caplog), caplog.at_level(logging.INFO):
- # Create a simple FastAPI app
- app = FastAPI(title="Test API")
-
- @app.get("/test")
- def test_endpoint():
- return {"message": "test"}
-
- # Create server using from_fastapi (should use legacy by default)
- server = FastMCP.from_fastapi(app=app)
-
- # Should be the legacy implementation
- assert isinstance(server, LegacyFastMCPOpenAPI)
-
- # Should have logged the message about using legacy parser
- legacy_log_messages = [
- record
- for record in caplog.records
- if "Using legacy OpenAPI parser" in record.message
- ]
- assert len(legacy_log_messages) == 1
- assert legacy_log_messages[0].levelno == logging.INFO
- assert (
- "FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER=true"
- in legacy_log_messages[0].message
- )
-
- def test_from_fastapi_uses_experimental_with_flag_and_no_log(
- self, caplog: LogCaptureFixture
- ):
- """Test that from_fastapi uses experimental parser with flag and emits no log."""
- # Capture all logs at INFO level and above
- with caplog.at_level(logging.INFO):
- # Create a simple FastAPI app
- app = FastAPI(title="Test API")
-
- @app.get("/test")
- def test_endpoint():
- return {"message": "test"}
-
- # Create server with experimental flag enabled
- with temporary_settings(experimental__enable_new_openapi_parser=True):
- server = FastMCP.from_fastapi(app=app)
-
- # Should be the experimental implementation
- assert isinstance(server, ExperimentalFastMCPOpenAPI)
-
- # Should not have logged the legacy parser message
- legacy_log_messages = [
- record
- for record in caplog.records
- if "Using legacy OpenAPI parser" in record.message
- ]
- assert len(legacy_log_messages) == 0
-
-
class TestSettingsFromEnvironment:
async def test_settings_from_environment_issue_1749(self):
"""Test that when auth is enabled, the server starts."""
diff --git a/tests/utilities/openapi/__init__.py b/tests/utilities/openapi/__init__.py
index 0be677d94..65fb50b8d 100644
--- a/tests/utilities/openapi/__init__.py
+++ b/tests/utilities/openapi/__init__.py
@@ -1 +1 @@
-"""Tests for the OpenAPI utilities."""
+"""Tests for openapi_new utilities."""
diff --git a/tests/utilities/openapi/conftest.py b/tests/utilities/openapi/conftest.py
index 8b1378917..b7158dd1c 100644
--- a/tests/utilities/openapi/conftest.py
+++ b/tests/utilities/openapi/conftest.py
@@ -1 +1,222 @@
+"""Shared fixtures for openapi_new utilities tests."""
+import pytest
+
+
+@pytest.fixture
+def basic_openapi_30_spec():
+ """Basic OpenAPI 3.0 spec for testing."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Test API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/users/{id}": {
+ "get": {
+ "operationId": "get_user",
+ "summary": "Get user by ID",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User retrieved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+
+
+@pytest.fixture
+def basic_openapi_31_spec():
+ """Basic OpenAPI 3.1 spec for testing."""
+ return {
+ "openapi": "3.1.0",
+ "info": {"title": "Test API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com"}],
+ "paths": {
+ "/users/{id}": {
+ "get": {
+ "operationId": "get_user",
+ "summary": "Get user by ID",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User retrieved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ }
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ }
+
+
+@pytest.fixture
+def collision_spec():
+ """OpenAPI spec with parameter name collisions."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Collision Test API", "version": "1.0.0"},
+ "paths": {
+ "/users/{id}": {
+ "put": {
+ "operationId": "update_user",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "integer"},
+ }
+ ],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ "required": ["name"],
+ }
+ }
+ },
+ },
+ "responses": {"200": {"description": "Updated"}},
+ }
+ }
+ },
+ }
+
+
+@pytest.fixture
+def deepobject_spec():
+ """OpenAPI spec with deepObject parameter style."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "DeepObject Test API", "version": "1.0.0"},
+ "paths": {
+ "/search": {
+ "get": {
+ "operationId": "search",
+ "parameters": [
+ {
+ "name": "filter",
+ "in": "query",
+ "required": False,
+ "style": "deepObject",
+ "explode": True,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "category": {"type": "string"},
+ "price": {
+ "type": "object",
+ "properties": {
+ "min": {"type": "number"},
+ "max": {"type": "number"},
+ },
+ },
+ },
+ },
+ }
+ ],
+ "responses": {"200": {"description": "Search results"}},
+ }
+ }
+ },
+ }
+
+
+@pytest.fixture
+def complex_spec():
+ """Complex OpenAPI spec with multiple parameter types."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "Complex API", "version": "1.0.0"},
+ "paths": {
+ "/items/{id}": {
+ "patch": {
+ "operationId": "update_item",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ },
+ {
+ "name": "version",
+ "in": "query",
+ "required": False,
+ "schema": {"type": "integer", "default": 1},
+ },
+ {
+ "name": "X-Client-Version",
+ "in": "header",
+ "required": False,
+ "schema": {"type": "string"},
+ },
+ ],
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "description": {"type": "string"},
+ "tags": {
+ "type": "array",
+ "items": {"type": "string"},
+ },
+ },
+ "required": ["title"],
+ }
+ }
+ },
+ },
+ "responses": {"200": {"description": "Item updated"}},
+ }
+ }
+ },
+ }
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_allof_requestbody.py b/tests/utilities/openapi/test_allof_requestbody.py
similarity index 98%
rename from tests/experimental/openapi_parser/utilities/openapi/test_allof_requestbody.py
rename to tests/utilities/openapi/test_allof_requestbody.py
index 0ab2faee8..65e60ef1e 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_allof_requestbody.py
+++ b/tests/utilities/openapi/test_allof_requestbody.py
@@ -1,10 +1,10 @@
"""Tests for allOf handling at requestBody top level."""
-from fastmcp.experimental.utilities.openapi.models import (
+from fastmcp.utilities.openapi.models import (
HTTPRoute,
RequestBodyInfo,
)
-from fastmcp.experimental.utilities.openapi.schemas import _combine_schemas
+from fastmcp.utilities.openapi.schemas import _combine_schemas
def test_allof_at_requestbody_top_level():
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_direct_array_schemas.py b/tests/utilities/openapi/test_direct_array_schemas.py
similarity index 98%
rename from tests/experimental/openapi_parser/utilities/openapi/test_direct_array_schemas.py
rename to tests/utilities/openapi/test_direct_array_schemas.py
index 02d641e08..b2c1a0cce 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_direct_array_schemas.py
+++ b/tests/utilities/openapi/test_direct_array_schemas.py
@@ -1,11 +1,11 @@
"""Test handling of direct array schemas in request bodies (FastAPI list parameters)."""
-from fastmcp.experimental.utilities.openapi.models import (
+from fastmcp.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
RequestBodyInfo,
)
-from fastmcp.experimental.utilities.openapi.schemas import (
+from fastmcp.utilities.openapi.schemas import (
_combine_schemas_and_map_params,
)
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py
similarity index 98%
rename from tests/experimental/openapi_parser/utilities/openapi/test_director.py
rename to tests/utilities/openapi/test_director.py
index e912fd1f6..849c91ef4 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_director.py
+++ b/tests/utilities/openapi/test_director.py
@@ -3,13 +3,13 @@
import pytest
from jsonschema_path import SchemaPath
-from fastmcp.experimental.utilities.openapi.director import RequestDirector
-from fastmcp.experimental.utilities.openapi.models import (
+from fastmcp.utilities.openapi.director import RequestDirector
+from fastmcp.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
RequestBodyInfo,
)
-from fastmcp.experimental.utilities.openapi.parser import parse_openapi_to_http_routes
+from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
class TestRequestDirector:
diff --git a/tests/utilities/openapi/test_legacy_compatibility.py b/tests/utilities/openapi/test_legacy_compatibility.py
new file mode 100644
index 000000000..a40ee6331
--- /dev/null
+++ b/tests/utilities/openapi/test_legacy_compatibility.py
@@ -0,0 +1,192 @@
+"""Tests to ensure OpenAPI schema generation works correctly."""
+
+import pytest
+
+from fastmcp.utilities.openapi.models import (
+ HTTPRoute,
+ ParameterInfo,
+ RequestBodyInfo,
+)
+from fastmcp.utilities.openapi.schemas import (
+ _combine_schemas_and_map_params,
+)
+
+
+class TestSchemaGeneration:
+ """Test that OpenAPI schema generation produces correct schemas."""
+
+ def test_optional_parameter_nullable_behavior(self):
+ """Test that optional parameters are not made nullable - they can simply be omitted."""
+ route = HTTPRoute(
+ method="GET",
+ path="/test",
+ operation_id="test_op",
+ parameters=[
+ ParameterInfo(
+ name="required_param",
+ location="query",
+ required=True,
+ schema={"type": "string"},
+ ),
+ ParameterInfo(
+ name="optional_param",
+ location="query",
+ required=False,
+ schema={"type": "string"},
+ ),
+ ],
+ )
+
+ schema, _ = _combine_schemas_and_map_params(route)
+
+ # Required parameter should have simple type
+ assert schema["properties"]["required_param"]["type"] == "string"
+ assert "anyOf" not in schema["properties"]["required_param"]
+
+ # Optional parameters should preserve original schema without making it nullable
+ assert "anyOf" not in schema["properties"]["optional_param"]
+ assert schema["properties"]["optional_param"]["type"] == "string"
+
+ # Required list should only include required parameters
+ assert "required_param" in schema["required"]
+ assert "optional_param" not in schema["required"]
+
+ def test_parameter_collision_handling(self):
+ """Test that parameter collisions are handled with suffixes."""
+ route = HTTPRoute(
+ method="PUT",
+ path="/users/{id}",
+ operation_id="update_user",
+ parameters=[
+ ParameterInfo(
+ name="id",
+ location="path",
+ required=True,
+ schema={"type": "integer"},
+ )
+ ],
+ request_body=RequestBodyInfo(
+ required=True,
+ content_schema={
+ "application/json": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "integer"},
+ "name": {"type": "string"},
+ },
+ "required": ["name"],
+ }
+ },
+ ),
+ )
+
+ schema, param_map = _combine_schemas_and_map_params(route)
+
+ # Should have path parameter with suffix
+ assert "id__path" in schema["properties"]
+
+ # Should have body parameter without suffix
+ assert "id" in schema["properties"]
+
+ # Should have name parameter from body
+ assert "name" in schema["properties"]
+
+ # Required should include path param (suffixed) and required body params
+ required = set(schema["required"])
+ assert "id__path" in required
+ assert "name" in required
+
+ # Parameter map should correctly map suffixed parameter
+ assert param_map["id__path"]["location"] == "path"
+ assert param_map["id__path"]["openapi_name"] == "id"
+ assert param_map["id"]["location"] == "body"
+ assert param_map["name"]["location"] == "body"
+
+ @pytest.mark.parametrize(
+ "param_type",
+ [
+ {"type": "integer"},
+ {"type": "number"},
+ {"type": "boolean"},
+ {"type": "array", "items": {"type": "string"}},
+ {"type": "object", "properties": {"name": {"type": "string"}}},
+ ],
+ )
+ def test_nullable_behavior_different_types(self, param_type):
+ """Test nullable behavior works for all parameter types."""
+ route = HTTPRoute(
+ method="GET",
+ path="/test",
+ operation_id="test_op",
+ parameters=[
+ ParameterInfo(
+ name="optional_param",
+ location="query",
+ required=False,
+ schema=param_type,
+ )
+ ],
+ )
+
+ schema, _ = _combine_schemas_and_map_params(route)
+
+ # Should preserve original schema without making it nullable
+ param = schema["properties"]["optional_param"]
+ assert "anyOf" not in param
+
+ # Should match the original parameter schema
+ for key, value in param_type.items():
+ assert param[key] == value
+
+ def test_no_parameters_no_body(self):
+ """Test schema generation when there are no parameters or body."""
+ route = HTTPRoute(
+ method="GET",
+ path="/health",
+ operation_id="health_check",
+ )
+
+ schema, param_map = _combine_schemas_and_map_params(route)
+
+ # Should have empty object schema
+ assert schema["type"] == "object"
+ assert schema["properties"] == {}
+ assert schema["required"] == []
+ assert param_map == {}
+
+ def test_body_only_no_parameters(self):
+ """Test schema generation with only request body, no parameters."""
+ body_schema = {
+ "application/json": {
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "description": {"type": "string"},
+ },
+ "required": ["title"],
+ }
+ }
+
+ route = HTTPRoute(
+ method="POST",
+ path="/items",
+ operation_id="create_item",
+ request_body=RequestBodyInfo(
+ required=True,
+ content_schema=body_schema,
+ ),
+ )
+
+ schema, param_map = _combine_schemas_and_map_params(route)
+
+ # Should have body properties
+ assert "title" in schema["properties"]
+ assert "description" in schema["properties"]
+
+ # Required should match body requirements
+ assert "title" in schema["required"]
+ assert "description" not in schema["required"]
+
+ # Parameter map should map body properties
+ assert param_map["title"]["location"] == "body"
+ assert param_map["description"]["location"] == "body"
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_models.py b/tests/utilities/openapi/test_models.py
similarity index 99%
rename from tests/experimental/openapi_parser/utilities/openapi/test_models.py
rename to tests/utilities/openapi/test_models.py
index 2a77be5f4..1f900b557 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_models.py
+++ b/tests/utilities/openapi/test_models.py
@@ -3,7 +3,7 @@
import pytest
from inline_snapshot import snapshot
-from fastmcp.experimental.utilities.openapi.models import (
+from fastmcp.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
RequestBodyInfo,
diff --git a/tests/utilities/openapi/test_nullable_fields.py b/tests/utilities/openapi/test_nullable_fields.py
index 1802e1a5d..176c1325c 100644
--- a/tests/utilities/openapi/test_nullable_fields.py
+++ b/tests/utilities/openapi/test_nullable_fields.py
@@ -1,6 +1,11 @@
"""Tests for nullable field handling in OpenAPI schemas."""
-from fastmcp.utilities.openapi import _handle_nullable_fields
+import pytest
+from jsonschema import ValidationError, validate
+
+from fastmcp.utilities.openapi.json_schema_converter import (
+ convert_openapi_schema_to_json_schema,
+)
class TestHandleNullableFields:
@@ -10,21 +15,21 @@ class TestHandleNullableFields:
"""Test nullable string at root level."""
input_schema = {"type": "string", "nullable": True}
expected = {"type": ["string", "null"]}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_root_level_nullable_integer(self):
"""Test nullable integer at root level."""
input_schema = {"type": "integer", "nullable": True}
expected = {"type": ["integer", "null"]}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_root_level_nullable_boolean(self):
"""Test nullable boolean at root level."""
input_schema = {"type": "boolean", "nullable": True}
expected = {"type": ["boolean", "null"]}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_nullable_fields(self):
@@ -47,7 +52,7 @@ class TestHandleNullableFields:
"active": {"type": ["boolean", "null"]},
},
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_mixed_nullable_and_non_nullable(self):
@@ -70,14 +75,14 @@ class TestHandleNullableFields:
},
"required": ["required_field"],
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_false_ignored(self):
"""Test that nullable: false is ignored (removed but no type change)."""
input_schema = {"type": "string", "nullable": False}
expected = {"type": "string"}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_no_nullable_field_unchanged(self):
@@ -87,14 +92,14 @@ class TestHandleNullableFields:
"properties": {"name": {"type": "string"}},
}
expected = input_schema.copy()
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_nullable_without_type_removes_nullable(self):
"""Test that nullable field is removed even without type."""
input_schema = {"nullable": True, "description": "Some field"}
expected = {"description": "Some field"}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_preserves_other_fields(self):
@@ -112,15 +117,15 @@ class TestHandleNullableFields:
"example": "test",
"format": "email",
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_non_dict_input_unchanged(self):
"""Test that non-dict inputs are returned unchanged."""
- assert _handle_nullable_fields("string") == "string" # type: ignore[arg-type]
- assert _handle_nullable_fields(123) == 123 # type: ignore[arg-type]
- assert _handle_nullable_fields(None) is None # type: ignore[arg-type]
- assert _handle_nullable_fields([1, 2, 3]) == [1, 2, 3] # type: ignore[arg-type]
+ assert convert_openapi_schema_to_json_schema("string", "3.0.0") == "string" # type: ignore[arg-type]
+ assert convert_openapi_schema_to_json_schema(123, "3.0.0") == 123 # type: ignore[arg-type]
+ assert convert_openapi_schema_to_json_schema(None, "3.0.0") is None # type: ignore[arg-type]
+ assert convert_openapi_schema_to_json_schema([1, 2, 3], "3.0.0") == [1, 2, 3] # type: ignore[arg-type]
def test_performance_optimization_no_copy_when_unchanged(self):
"""Test that schemas without nullable fields return the same object (no copy)."""
@@ -128,7 +133,7 @@ class TestHandleNullableFields:
"type": "object",
"properties": {"name": {"type": "string"}},
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
# Should return the exact same object, not a copy
assert result is input_schema
@@ -136,14 +141,14 @@ class TestHandleNullableFields:
"""Test nullable handling with existing union types (type as array)."""
input_schema = {"type": ["string", "integer"], "nullable": True}
expected = {"type": ["string", "integer", "null"]}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_already_nullable_union_unchanged(self):
"""Test that union types already containing null are not modified."""
input_schema = {"type": ["string", "null"], "nullable": True}
expected = {"type": ["string", "null"]}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_union_with_nullable(self):
@@ -156,19 +161,19 @@ class TestHandleNullableFields:
"type": "object",
"properties": {"value": {"type": ["string", "integer", "null"]}},
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_complex_union_nullable_scenarios(self):
"""Test various complex union type scenarios."""
# Already has null in different position
input1 = {"type": ["null", "string", "integer"], "nullable": True}
- result1 = _handle_nullable_fields(input1)
+ result1 = convert_openapi_schema_to_json_schema(input1, "3.0.0")
assert result1 == {"type": ["null", "string", "integer"]}
# Single item array
input2 = {"type": ["string"], "nullable": True}
- result2 = _handle_nullable_fields(input2)
+ result2 = convert_openapi_schema_to_json_schema(input2, "3.0.0")
assert result2 == {"type": ["string", "null"]}
def test_oneof_with_nullable(self):
@@ -180,7 +185,7 @@ class TestHandleNullableFields:
expected = {
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_anyof_with_nullable(self):
@@ -192,7 +197,7 @@ class TestHandleNullableFields:
expected = {
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_anyof_already_nullable(self):
@@ -202,7 +207,7 @@ class TestHandleNullableFields:
"nullable": True,
}
expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_allof_with_nullable(self):
@@ -217,7 +222,7 @@ class TestHandleNullableFields:
{"type": "null"},
]
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
def test_property_level_oneof_with_nullable(self):
@@ -239,5 +244,132 @@ class TestHandleNullableFields:
}
},
}
- result = _handle_nullable_fields(input_schema)
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
assert result == expected
+
+ def test_nullable_enum_field(self):
+ """Test nullable enum field - issue #2082."""
+ input_schema = {
+ "type": "string",
+ "nullable": True,
+ "enum": ["VALUE1", "VALUE2", "VALUE3"],
+ }
+ expected = {
+ "type": ["string", "null"],
+ "enum": ["VALUE1", "VALUE2", "VALUE3", None],
+ }
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
+ assert result == expected
+
+ def test_nullable_enum_already_contains_null(self):
+ """Test nullable enum that already contains None."""
+ input_schema = {
+ "type": "string",
+ "nullable": True,
+ "enum": ["VALUE1", "VALUE2", None],
+ }
+ expected = {
+ "type": ["string", "null"],
+ "enum": ["VALUE1", "VALUE2", None],
+ }
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
+ assert result == expected
+
+ def test_nullable_enum_without_type(self):
+ """Test nullable enum without explicit type field."""
+ input_schema = {
+ "nullable": True,
+ "enum": ["VALUE1", "VALUE2", "VALUE3"],
+ }
+ expected = {
+ "enum": ["VALUE1", "VALUE2", "VALUE3", None],
+ }
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
+ assert result == expected
+
+ def test_non_nullable_enum_unchanged(self):
+ """Test that enum without nullable is unchanged."""
+ input_schema = {
+ "type": "string",
+ "enum": ["VALUE1", "VALUE2", "VALUE3"],
+ }
+ expected = {
+ "type": "string",
+ "enum": ["VALUE1", "VALUE2", "VALUE3"],
+ }
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
+ assert result == expected
+
+ def test_property_level_nullable_enum(self):
+ """Test nullable enum in object properties."""
+ input_schema = {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "nullable": True,
+ "enum": ["ACTIVE", "INACTIVE", "PENDING"],
+ },
+ "name": {"type": "string"},
+ },
+ }
+ expected = {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": ["string", "null"],
+ "enum": ["ACTIVE", "INACTIVE", "PENDING", None],
+ },
+ "name": {"type": "string"},
+ },
+ }
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
+ assert result == expected
+
+ def test_nullable_integer_enum(self):
+ """Test nullable enum with integer values."""
+ input_schema = {
+ "type": "integer",
+ "nullable": True,
+ "enum": [1, 2, 3],
+ }
+ expected = {
+ "type": ["integer", "null"],
+ "enum": [1, 2, 3, None],
+ }
+ result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
+ assert result == expected
+
+
+class TestNullableFieldValidation:
+ """Test that converted schemas validate correctly with jsonschema."""
+
+ def test_nullable_string_validates(self):
+ """Test that nullable string validates both null and string values."""
+ openapi_schema = {"type": "string", "nullable": True}
+ json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0")
+
+ # Both null and string should validate
+ validate(instance=None, schema=json_schema)
+ validate(instance="test", schema=json_schema)
+
+ # Other types should fail
+ with pytest.raises(ValidationError):
+ validate(instance=123, schema=json_schema)
+
+ def test_nullable_enum_validates(self):
+ """Test that nullable enum validates null, enum values, and rejects invalid values."""
+ openapi_schema = {
+ "type": "string",
+ "nullable": True,
+ "enum": ["VALUE1", "VALUE2", "VALUE3"],
+ }
+ json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0")
+
+ # Null and enum values should validate
+ validate(instance=None, schema=json_schema)
+ validate(instance="VALUE1", schema=json_schema)
+
+ # Invalid values should fail
+ with pytest.raises(ValidationError):
+ validate(instance="INVALID", schema=json_schema)
diff --git a/tests/utilities/openapi/test_openapi.py b/tests/utilities/openapi/test_openapi.py
deleted file mode 100644
index f9d613281..000000000
--- a/tests/utilities/openapi/test_openapi.py
+++ /dev/null
@@ -1,1285 +0,0 @@
-"""Tests for the OpenAPI parsing utilities."""
-
-from collections.abc import Sequence
-from typing import Any
-
-import pytest
-from fastapi import Body, FastAPI, Path, Query
-from inline_snapshot import snapshot
-from pydantic import BaseModel, Field
-
-from fastmcp.utilities.openapi import (
- HttpMethod,
- HTTPRoute,
- ParameterInfo,
- _combine_schemas,
- _replace_ref_with_defs,
- parse_openapi_to_http_routes,
-)
-
-# --- Test Data: Static OpenAPI Schema Dictionaries --- #
-
-
-@pytest.fixture
-def petstore_schema() -> dict[str, Any]:
- """Fixture that returns a simple Pet Store API schema."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Simple Pet Store API", "version": "1.0.0"},
- "paths": {
- "/pets": {
- "get": {
- "summary": "List all pets",
- "operationId": "listPets",
- "tags": ["pets"],
- "parameters": [
- {
- "name": "limit",
- "in": "query",
- "description": "How many items to return",
- "required": False,
- "schema": {"type": "integer", "format": "int32"},
- }
- ],
- "responses": {"200": {"description": "A paged array of pets"}},
- },
- "post": {
- "summary": "Create a pet",
- "operationId": "createPet",
- "tags": ["pets"],
- "requestBody": {"$ref": "#/components/requestBodies/PetBody"},
- "responses": {"201": {"description": "Null response"}},
- },
- },
- "/pets/{petId}": {
- "get": {
- "summary": "Info for a specific pet",
- "operationId": "showPetById",
- "tags": ["pets"],
- "parameters": [
- {
- "name": "petId",
- "in": "path",
- "required": True,
- "description": "The id of the pet",
- "schema": {"type": "string"},
- },
- {
- "name": "X-Request-ID",
- "in": "header",
- "required": False,
- "schema": {"type": "string", "format": "uuid"},
- },
- ],
- "responses": {"200": {"description": "Information about the pet"}},
- },
- "parameters": [ # Path level parameter example
- {
- "name": "traceId",
- "in": "header",
- "description": "Common trace ID",
- "required": False,
- "schema": {"type": "string"},
- }
- ],
- },
- },
- "components": {
- "schemas": {
- "Pet": {
- "type": "object",
- "required": ["id", "name"],
- "properties": {
- "id": {"type": "integer", "format": "int64"},
- "name": {"type": "string"},
- "tag": {"type": "string"},
- },
- }
- },
- "requestBodies": {
- "PetBody": {
- "description": "Pet object",
- "required": True,
- "content": {
- "application/json": {
- "schema": {"$ref": "#/components/schemas/Pet"}
- }
- },
- }
- },
- },
- }
-
-
-@pytest.fixture
-def parsed_petstore_routes(petstore_schema: dict[str, Any]) -> list[HTTPRoute]:
- """Return parsed routes from the PetStore schema."""
- return parse_openapi_to_http_routes(petstore_schema)
-
-
-@pytest.fixture
-def bookstore_schema() -> dict[str, Any]:
- """Fixture that returns a Book Store API schema with different parameter types."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Book Store API", "version": "1.0.0"},
- "paths": {
- "/books": {
- "get": {
- "summary": "List all books",
- "operationId": "listBooks",
- "tags": ["books"],
- "parameters": [
- {
- "name": "genre",
- "in": "query",
- "description": "Filter by genre",
- "required": False,
- "schema": {"type": "string"},
- },
- {
- "name": "published_after",
- "in": "query",
- "description": "Filter by publication date",
- "required": False,
- "schema": {"type": "string", "format": "date"},
- },
- {
- "name": "limit",
- "in": "query",
- "description": "Maximum number of results",
- "required": False,
- "schema": {"type": "integer", "default": 10},
- },
- ],
- "responses": {"200": {"description": "A list of books"}},
- },
- "post": {
- "summary": "Create a new book",
- "operationId": "createBook",
- "tags": ["books"],
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": ["title", "author"],
- "properties": {
- "title": {"type": "string"},
- "author": {"type": "string"},
- "isbn": {"type": "string"},
- "published": {
- "type": "string",
- "format": "date",
- },
- "genre": {"type": "string"},
- },
- }
- }
- },
- },
- "responses": {"201": {"description": "Book created"}},
- },
- },
- "/books/{isbn}": {
- "get": {
- "summary": "Get book by ISBN",
- "operationId": "getBook",
- "tags": ["books"],
- "parameters": [
- {
- "name": "isbn",
- "in": "path",
- "required": True,
- "description": "ISBN of the book",
- "schema": {"type": "string"},
- }
- ],
- "responses": {"200": {"description": "Book details"}},
- },
- "delete": {
- "summary": "Delete a book",
- "operationId": "deleteBook",
- "tags": ["books"],
- "parameters": [
- {
- "name": "isbn",
- "in": "path",
- "required": True,
- "description": "ISBN of the book to delete",
- "schema": {"type": "string"},
- }
- ],
- "responses": {"204": {"description": "Book deleted"}},
- },
- },
- },
- }
-
-
-@pytest.fixture
-def parsed_bookstore_routes(bookstore_schema: dict[str, Any]) -> list[HTTPRoute]:
- """Return parsed routes from the BookStore schema."""
- return parse_openapi_to_http_routes(bookstore_schema)
-
-
-def get_route(
- routes: list[HTTPRoute], method: HttpMethod, path: str
-) -> HTTPRoute | None:
- """Get a route by method and path."""
- return next((r for r in routes if r.method == method and r.path == path), None)
-
-
-def get_parameter(
- parameters: Sequence[ParameterInfo], name: str
-) -> ParameterInfo | None:
- """Get a parameter by name."""
- return next((p for p in parameters if p.name == name), None)
-
-
-def dump_models(models: Sequence[BaseModel], **kwargs: Any) -> list[dict[str, Any]]:
- """Dump a list of models to a list of dictionaries."""
- return [m.model_dump(**kwargs) for m in models]
-
-
-# --- FastAPI App Fixtures --- #
-
-
-class Item(BaseModel):
- """Example pydantic model for API testing."""
-
- name: str
- description: str | None = None
- price: float
- tax: float | None = None
- tags: list[str] = Field(default_factory=list)
-
-
-@pytest.fixture
-def fastapi_app() -> FastAPI:
- """Fixture that returns a FastAPI app with various types of endpoints."""
- app = FastAPI(title="Test API", version="1.0.0")
-
- @app.get("/items/", operation_id="list_items")
- async def list_items(skip: int = 0, limit: int = 10):
- """List all items with pagination."""
- return [
- {"name": f"Item {i}", "price": float(i)} for i in range(skip, skip + limit)
- ]
-
- @app.post("/items/", operation_id="create_item")
- async def create_item(item: Item):
- """Create a new item."""
- return item
-
- @app.get("/items/{item_id}", operation_id="get_item")
- async def get_item(
- item_id: int = Path(..., description="The ID of the item to get"),
- q: str | None = Query(None, description="Optional query string"),
- ):
- """Get an item by ID."""
- return {"item_id": item_id, "q": q}
-
- @app.put("/items/{item_id}", operation_id="update_item")
- async def update_item(
- item_id: int = Path(..., description="The ID of the item to update"),
- item: Item = Body(..., description="The updated item data"),
- ):
- """Update an existing item."""
- return {"item_id": item_id, **item.model_dump()}
-
- @app.delete("/items/{item_id}", operation_id="delete_item")
- async def delete_item(
- item_id: int = Path(..., description="The ID of the item to delete"),
- ):
- """Delete an item by ID."""
- return {"item_id": item_id, "deleted": True}
-
- @app.get("/items/{item_id}/tags/{tag_id}", operation_id="get_item_tag")
- async def get_item_tag(
- item_id: int = Path(..., description="The ID of the item"),
- tag_id: str = Path(..., description="The ID of the tag"),
- ):
- """Get a specific tag for an item."""
- return {"item_id": item_id, "tag_id": tag_id}
-
- @app.post("/upload/", operation_id="upload_file")
- async def upload_file(
- file_name: str = Query(..., description="Name of the file to upload"),
- content_type: str = Query(..., description="Content type of the file"),
- ):
- """Upload a file (dummy endpoint for testing query params with POST)."""
- return {
- "file_name": file_name,
- "content_type": content_type,
- "status": "uploaded",
- }
-
- return app
-
-
-@pytest.fixture
-def fastapi_openapi_schema(fastapi_app) -> dict[str, Any]:
- """Fixture that returns the OpenAPI schema of the FastAPI app."""
- return fastapi_app.openapi()
-
-
-@pytest.fixture
-def parsed_fastapi_routes(fastapi_openapi_schema: dict[str, Any]) -> list[HTTPRoute]:
- """Return parsed routes from a FastAPI OpenAPI schema."""
- return parse_openapi_to_http_routes(fastapi_openapi_schema)
-
-
-@pytest.fixture
-def fastapi_route_map(parsed_fastapi_routes: list[HTTPRoute]) -> dict[str, HTTPRoute]:
- """Return a dictionary of routes by operation ID."""
- return {
- r.operation_id: r for r in parsed_fastapi_routes if r.operation_id is not None
- }
-
-
-@pytest.fixture
-def openapi_30_schema() -> dict[str, Any]:
- """Fixture that returns a simple OpenAPI 3.0.0 schema."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "Simple API (OpenAPI 3.0)", "version": "1.0.0"},
- "paths": {
- "/items": {
- "get": {
- "summary": "List all items",
- "operationId": "listItems",
- "parameters": [
- {
- "name": "limit",
- "in": "query",
- "description": "How many items to return",
- "required": False,
- "schema": {"type": "integer"},
- }
- ],
- "responses": {"200": {"description": "A list of items"}},
- }
- }
- },
- }
-
-
-@pytest.fixture
-def openapi_31_schema() -> dict[str, Any]:
- """Fixture that returns a simple OpenAPI 3.1.0 schema."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Simple API (OpenAPI 3.1)", "version": "1.0.0"},
- "paths": {
- "/items": {
- "get": {
- "summary": "List all items",
- "operationId": "listItems",
- "parameters": [
- {
- "name": "limit",
- "in": "query",
- "description": "How many items to return",
- "required": False,
- "schema": {"type": "integer"},
- }
- ],
- "responses": {"200": {"description": "A list of items"}},
- }
- }
- },
- }
-
-
-@pytest.fixture
-def openapi_30_with_references() -> dict[str, Any]:
- """OpenAPI 3.0 schema with references to test resolution."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "API with References (3.0)", "version": "1.0.0"},
- "paths": {
- "/products": {
- "post": {
- "summary": "Create product",
- "operationId": "createProduct",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {"$ref": "#/components/schemas/Product"}
- }
- },
- "required": True,
- },
- "responses": {
- "201": {
- "description": "Product created",
- "content": {
- "application/json": {
- "schema": {"$ref": "#/components/schemas/Product"}
- }
- },
- }
- },
- }
- }
- },
- "components": {
- "schemas": {
- "Product": {
- "type": "object",
- "required": ["name", "price"],
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "price": {"type": "number"},
- "category": {"$ref": "#/components/schemas/Category"},
- },
- },
- "Category": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- },
- }
- },
- }
-
-
-@pytest.fixture
-def openapi_31_with_references() -> dict[str, Any]:
- """OpenAPI 3.1 schema with references to test resolution."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "API with References (3.1)", "version": "1.0.0"},
- "paths": {
- "/products": {
- "post": {
- "summary": "Create product",
- "operationId": "createProduct",
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {"$ref": "#/components/schemas/Product"}
- }
- },
- "required": True,
- },
- "responses": {
- "201": {
- "description": "Product created",
- "content": {
- "application/json": {
- "schema": {"$ref": "#/components/schemas/Product"}
- }
- },
- }
- },
- }
- }
- },
- "components": {
- "schemas": {
- "Product": {
- "type": "object",
- "required": ["name", "price"],
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "price": {"type": "number"},
- "category": {"$ref": "#/components/schemas/Category"},
- },
- },
- "Category": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- },
- }
- },
- }
-
-
-# --- Tests for PetStore schema --- #
-
-
-def test_petstore_route_count(parsed_petstore_routes: list[HTTPRoute]):
- """Test that parsing the PetStore schema correctly identifies the number of routes."""
- assert len(parsed_petstore_routes) == 3
-
-
-def test_petstore_get_pets_operation_id(parsed_petstore_routes: list[HTTPRoute]):
- """Test that GET /pets operation_id is correctly parsed."""
- get_pets = get_route(parsed_petstore_routes, "GET", "/pets")
- assert get_pets is not None
- assert get_pets.operation_id == "listPets"
-
-
-def test_petstore_query_parameter(parsed_petstore_routes: list[HTTPRoute]):
- """Test that query parameter 'limit' is correctly parsed from the schema."""
- get_pets = get_route(parsed_petstore_routes, "GET", "/pets")
-
- assert get_pets is not None
- assert dump_models(get_pets.parameters, exclude_none=True) == snapshot(
- [
- {
- "name": "limit",
- "location": "query",
- "required": False,
- "schema_": {"type": "integer", "format": "int32"},
- "description": "How many items to return",
- }
- ]
- )
-
-
-def test_petstore_path_parameter(parsed_petstore_routes: list[HTTPRoute]):
- """Test that path parameter 'petId' is correctly parsed from the schema."""
- get_pet = get_route(parsed_petstore_routes, "GET", "/pets/{petId}")
- assert get_pet is not None
-
- path_param = get_parameter(get_pet.parameters, "petId")
- assert path_param is not None
-
- assert path_param.model_dump(exclude_none=True) == snapshot(
- {
- "name": "petId",
- "location": "path",
- "required": True,
- "schema_": {"type": "string"},
- "description": "The id of the pet",
- }
- )
-
-
-def test_petstore_header_parameters(parsed_petstore_routes: list[HTTPRoute]):
- """Test that header parameters are correctly parsed from the schema."""
- get_pet = get_route(parsed_petstore_routes, "GET", "/pets/{petId}")
- assert get_pet is not None
-
- header_params = [p for p in get_pet.parameters if p.location == "header"]
- assert dump_models(header_params, exclude_none=True) == snapshot(
- [
- {
- "name": "X-Request-ID",
- "location": "header",
- "required": False,
- "schema_": {"type": "string", "format": "uuid"},
- },
- {
- "name": "traceId",
- "location": "header",
- "required": False,
- "schema_": {"type": "string"},
- "description": "Common trace ID",
- },
- ]
- )
-
-
-def test_petstore_path_level_parameters(parsed_petstore_routes: list[HTTPRoute]):
- """Test that path-level parameters are correctly merged into the operation."""
- get_pet = get_route(parsed_petstore_routes, "GET", "/pets/{petId}")
- assert get_pet is not None
-
- trace_param = get_parameter(get_pet.parameters, "traceId")
- assert trace_param is not None
-
- assert trace_param.model_dump(exclude_none=True) == snapshot(
- {
- "name": "traceId",
- "location": "header",
- "required": False,
- "schema_": {"type": "string"},
- "description": "Common trace ID",
- }
- )
-
-
-def test_petstore_request_body_reference_resolution(
- parsed_petstore_routes: list[HTTPRoute],
-):
- """Test that request body references are correctly resolved."""
- create_pet = get_route(parsed_petstore_routes, "POST", "/pets")
-
- assert create_pet is not None
- assert create_pet.request_body is not None
- assert create_pet.request_body.required is True
- assert "application/json" in create_pet.request_body.content_schema
-
-
-def test_petstore_schema_reference_resolution(parsed_petstore_routes: list[HTTPRoute]):
- """Test that schema references in request bodies are correctly resolved."""
- create_pet = get_route(parsed_petstore_routes, "POST", "/pets")
-
- assert create_pet is not None
- assert create_pet.request_body is not None
- json_schema = create_pet.request_body.content_schema["application/json"]
- properties = json_schema.get("properties", {})
-
- assert "id" in properties
- assert "name" in properties
- assert "tag" in properties
-
-
-def test_petstore_required_fields_resolution(parsed_petstore_routes: list[HTTPRoute]):
- """Test that required fields are correctly resolved from referenced schemas."""
- create_pet = get_route(parsed_petstore_routes, "POST", "/pets")
-
- assert create_pet is not None
- assert create_pet.request_body is not None
- json_schema = create_pet.request_body.content_schema["application/json"]
- assert json_schema.get("required") == ["id", "name"]
-
-
-def test_tags_parsing_in_petstore_routes(parsed_petstore_routes: list[HTTPRoute]):
- """Test that tags are correctly parsed from the OpenAPI schema."""
- # All petstore routes should have the "pets" tag
- for route in parsed_petstore_routes:
- assert "pets" in route.tags, (
- f"Route {route.method} {route.path} is missing 'pets' tag"
- )
-
-
-def test_tag_list_structure(parsed_petstore_routes: list[HTTPRoute]):
- """Test that tags are stored as a list of strings."""
- for route in parsed_petstore_routes:
- assert isinstance(route.tags, list), "Tags should be stored as a list"
- for tag in route.tags:
- assert isinstance(tag, str), "Each tag should be a string"
-
-
-def test_empty_tags_handling(bookstore_schema: dict[str, Any]):
- """Test that routes with no tags are handled correctly with empty lists."""
- # Modify a route to remove tags
- if "tags" in bookstore_schema["paths"]["/books"]["get"]:
- del bookstore_schema["paths"]["/books"]["get"]["tags"]
-
- # Parse the modified schema
- routes = parse_openapi_to_http_routes(bookstore_schema)
-
- # Find the GET /books route
- get_books = get_route(routes, "GET", "/books")
- assert get_books is not None
-
- # Should have an empty list, not None
- assert get_books.tags == [], "Routes without tags should have empty tag lists"
-
-
-def test_multiple_tags_preserved(bookstore_schema: dict[str, Any]):
- """Test that multiple tags are preserved during parsing."""
- # Add multiple tags to a route
- bookstore_schema["paths"]["/books"]["get"]["tags"] = ["books", "catalog", "api"]
-
- # Parse the modified schema
- routes = parse_openapi_to_http_routes(bookstore_schema)
-
- # Find the GET /books route
- get_books = get_route(routes, "GET", "/books")
- assert get_books is not None
-
- # Should have all tags
- assert "books" in get_books.tags
- assert "catalog" in get_books.tags
- assert "api" in get_books.tags
- assert len(get_books.tags) == 3
-
-
-def test_openapi_extensions(petstore_schema: dict[str, Any]):
- """Test that OpenAPI extensions (x-*) are correctly parsed from operations."""
- # Add extensions to a route
- petstore_schema["paths"]["/pets"]["get"]["x-rate-limit"] = 100
- petstore_schema["paths"]["/pets"]["get"]["x-custom-auth"] = "bearer"
- petstore_schema["paths"]["/pets"]["get"]["x-internal"] = True
-
- # Parse the modified schema
- routes = parse_openapi_to_http_routes(petstore_schema)
-
- # Find the GET /pets route
- get_pets = get_route(routes, "GET", "/pets")
- assert get_pets is not None
-
- # Should have extensions
- assert get_pets.extensions["x-rate-limit"] == 100
- assert get_pets.extensions["x-custom-auth"] == "bearer"
- assert get_pets.extensions["x-internal"] is True
- assert len(get_pets.extensions) == 3
-
-
-# --- Tests for BookStore schema --- #
-
-
-def test_bookstore_route_count(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that parsing the BookStore schema correctly identifies the number of routes."""
- assert len(parsed_bookstore_routes) == 4
-
-
-def test_bookstore_query_parameter_count(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that the correct number of query parameters are parsed."""
- list_books = get_route(parsed_bookstore_routes, "GET", "/books")
-
- assert list_books is not None
- assert len(list_books.parameters) == 3
-
-
-def test_bookstore_query_parameter_names(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that query parameter names are correctly parsed."""
- list_books = get_route(parsed_bookstore_routes, "GET", "/books")
-
- assert list_books is not None
- param_map = {p.name: p for p in list_books.parameters}
- assert "genre" in param_map
- assert "published_after" in param_map
- assert "limit" in param_map
-
-
-def test_bookstore_query_parameter_formats(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that query parameter formats are correctly parsed."""
- list_books = get_route(parsed_bookstore_routes, "GET", "/books")
-
- assert list_books is not None
- param_map = {p.name: p for p in list_books.parameters}
- assert param_map["published_after"].schema_.get("format") == "date"
-
-
-def test_bookstore_query_parameter_defaults(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that query parameter default values are correctly parsed."""
- list_books = get_route(parsed_bookstore_routes, "GET", "/books")
-
- assert list_books is not None
- param_map = {p.name: p for p in list_books.parameters}
- assert param_map["limit"].schema_.get("default") == 10
-
-
-def test_bookstore_inline_request_body_presence(
- parsed_bookstore_routes: list[HTTPRoute],
-):
- """Test that request bodies with inline schemas are present."""
- create_book = get_route(parsed_bookstore_routes, "POST", "/books")
-
- assert create_book is not None
- assert create_book.request_body is not None
- assert create_book.request_body.required is True
- assert "application/json" in create_book.request_body.content_schema
-
-
-def test_bookstore_inline_request_body_properties(
- parsed_bookstore_routes: list[HTTPRoute],
-):
- """Test that request body properties are correctly parsed from inline schemas."""
- create_book = get_route(parsed_bookstore_routes, "POST", "/books")
-
- assert create_book is not None
- assert create_book.request_body is not None
-
- json_schema = create_book.request_body.content_schema["application/json"]
- assert json_schema == snapshot(
- {
- "properties": {
- "title": {"type": "string"},
- "author": {"type": "string"},
- "isbn": {"type": "string"},
- "published": {"type": "string", "format": "date"},
- "genre": {"type": "string"},
- },
- "type": "object",
- "required": ["title", "author"],
- }
- )
-
-
-def test_bookstore_inline_request_body_required_fields(
- parsed_bookstore_routes: list[HTTPRoute],
-):
- """Test that required fields in inline schema are correctly parsed."""
- create_book = get_route(parsed_bookstore_routes, "POST", "/books")
-
- assert create_book is not None
- assert create_book.request_body is not None
-
- json_schema = create_book.request_body.content_schema["application/json"]
- assert json_schema.get("required") == ["title", "author"]
-
-
-def test_bookstore_delete_method(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that DELETE method is correctly parsed from the schema."""
- delete_book = get_route(parsed_bookstore_routes, "DELETE", "/books/{isbn}")
-
- assert delete_book is not None
- assert delete_book.operation_id == "deleteBook"
- assert delete_book.path == "/books/{isbn}"
-
-
-def test_bookstore_delete_method_parameters(parsed_bookstore_routes: list[HTTPRoute]):
- """Test that parameters for DELETE method are correctly parsed."""
- delete_book = get_route(parsed_bookstore_routes, "DELETE", "/books/{isbn}")
-
- assert delete_book is not None
- assert len(delete_book.parameters) == 1
- assert delete_book.parameters[0].name == "isbn"
-
-
-# --- Tests for FastAPI Generated Schema --- #
-
-
-def test_fastapi_route_count(parsed_fastapi_routes: list[HTTPRoute]):
- """Test that parsing a FastAPI-generated schema correctly identifies the number of routes."""
- assert len(parsed_fastapi_routes) == 7
-
-
-def test_fastapi_parameter_default_values(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that default parameter values are correctly parsed from the schema."""
- list_items = fastapi_route_map["list_items"]
-
- param_map = {p.name: p for p in list_items.parameters}
- assert "skip" in param_map
- assert "limit" in param_map
-
-
-def test_fastapi_skip_parameter_default(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that skip parameter default value is correctly parsed."""
- list_items = fastapi_route_map["list_items"]
-
- param_map = {p.name: p for p in list_items.parameters}
- assert param_map["skip"].schema_.get("default") == 0
-
-
-def test_fastapi_limit_parameter_default(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that limit parameter default value is correctly parsed."""
- list_items = fastapi_route_map["list_items"]
-
- param_map = {p.name: p for p in list_items.parameters}
- assert param_map["limit"].schema_.get("default") == 10
-
-
-def test_fastapi_request_body_from_pydantic(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that request bodies from Pydantic models are present."""
- create_item = fastapi_route_map["create_item"]
-
- assert create_item.request_body is not None
- assert "application/json" in create_item.request_body.content_schema
-
-
-def test_fastapi_request_body_properties(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that request body properties from Pydantic models are correctly parsed."""
- create_item = fastapi_route_map["create_item"]
-
- assert create_item.request_body is not None
-
- json_schema = create_item.request_body.content_schema["application/json"]
- properties = json_schema.get("properties", {})
-
- assert "name" in properties
- assert "description" in properties
- assert "price" in properties
- assert "tax" in properties
- assert "tags" in properties
-
-
-def test_fastapi_request_body_required_fields(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that required fields from Pydantic models are correctly parsed."""
- create_item = fastapi_route_map["create_item"]
-
- assert create_item.request_body is not None
-
- json_schema = create_item.request_body.content_schema["application/json"]
- required = json_schema.get("required", [])
-
- assert "name" in required
- assert "price" in required
-
-
-def test_fastapi_path_parameter_presence(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that path parameters are present in FastAPI schema."""
- get_item = fastapi_route_map["get_item"]
-
- path_params = [p for p in get_item.parameters if p.location == "path"]
- assert len(path_params) == 1
-
-
-def test_fastapi_path_parameter_properties(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that path parameters properties are correctly parsed."""
- get_item = fastapi_route_map["get_item"]
-
- path_params = [p for p in get_item.parameters if p.location == "path"]
- assert path_params[0].name == "item_id"
- assert path_params[0].required is True
-
-
-def test_fastapi_optional_query_parameter(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that optional query parameters are correctly parsed."""
- get_item = fastapi_route_map["get_item"]
-
- query_params = [p for p in get_item.parameters if p.location == "query"]
- assert len(query_params) == 1
- assert query_params[0].name == "q"
- assert query_params[0].required is False
-
-
-def test_fastapi_multiple_path_parameter_count(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that multiple path parameters count is correct."""
- get_item_tag = fastapi_route_map["get_item_tag"]
-
- path_params = [p for p in get_item_tag.parameters if p.location == "path"]
- assert len(path_params) == 2
-
-
-def test_fastapi_multiple_path_parameter_names(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that multiple path parameter names are correctly parsed."""
- get_item_tag = fastapi_route_map["get_item_tag"]
-
- path_params = [p for p in get_item_tag.parameters if p.location == "path"]
- param_names = [p.name for p in path_params]
- assert "item_id" in param_names
- assert "tag_id" in param_names
-
-
-def test_fastapi_post_with_query_parameters(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that query parameters for POST methods are correctly parsed."""
- upload_file = fastapi_route_map["upload_file"]
-
- assert upload_file.method == "POST"
- query_params = [p for p in upload_file.parameters if p.location == "query"]
- assert dump_models(query_params, exclude_none=True) == snapshot(
- [
- {
- "name": "file_name",
- "location": "query",
- "required": True,
- "schema_": {
- "type": "string",
- "title": "File Name",
- "description": "Name of the file to upload",
- },
- "description": "Name of the file to upload",
- },
- {
- "name": "content_type",
- "location": "query",
- "required": True,
- "schema_": {
- "type": "string",
- "title": "Content Type",
- "description": "Content type of the file",
- },
- "description": "Content type of the file",
- },
- ]
- )
-
-
-def test_fastapi_post_query_parameter_names(fastapi_route_map: dict[str, HTTPRoute]):
- """Test that query parameter names for POST methods are correctly parsed."""
- upload_file = fastapi_route_map["upload_file"]
-
- query_params = [p for p in upload_file.parameters if p.location == "query"]
- param_names = [p.name for p in query_params]
- assert "file_name" in param_names
- assert "content_type" in param_names
-
-
-def test_openapi_30_compatibility(openapi_30_schema: dict[str, Any]):
- """Test that OpenAPI 3.0 schemas can be parsed correctly."""
- # This will raise an exception if the parser doesn't support 3.0.0
- routes = parse_openapi_to_http_routes(openapi_30_schema)
-
- # Verify the route was parsed correctly
- assert len(routes) == 1
- route = routes[0]
- assert route.method == "GET"
- assert route.path == "/items"
- assert route.operation_id == "listItems"
- assert len(route.parameters) == 1
- assert route.parameters[0].name == "limit"
-
-
-def test_openapi_31_compatibility(openapi_31_schema: dict[str, Any]):
- """Test that OpenAPI 3.1 schemas can be parsed correctly."""
- routes = parse_openapi_to_http_routes(openapi_31_schema)
-
- # Verify the route was parsed correctly
- assert len(routes) == 1
- route = routes[0]
- assert route.method == "GET"
- assert route.path == "/items"
- assert route.operation_id == "listItems"
- assert len(route.parameters) == 1
- assert route.parameters[0].name == "limit"
-
-
-def test_version_detection_logic():
- """Test that the version detection logic correctly identifies 3.0 vs 3.1 schemas."""
- # Test 3.0 variations
- for version in ["3.0.0", "3.0.1", "3.0.3"]:
- schema = {
- "openapi": version,
- "info": {"title": "Test", "version": "1.0.0"},
- "paths": {},
- }
- try:
- parse_openapi_to_http_routes(schema)
- # Expect no error
- except Exception as e:
- pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}")
-
- # Test 3.1 variations
- for version in ["3.1.0", "3.1.1"]:
- schema = {
- "openapi": version,
- "info": {"title": "Test", "version": "1.0.0"},
- "paths": {},
- }
- try:
- parse_openapi_to_http_routes(schema)
- # Expect no error
- except Exception as e:
- pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}")
-
-
-def test_openapi_30_reference_resolution(openapi_30_with_references: dict[str, Any]):
- """Test that references are correctly resolved in OpenAPI 3.0 schemas."""
- routes = parse_openapi_to_http_routes(openapi_30_with_references)
-
- assert len(routes) == 1
- route = routes[0]
- assert route.method == "POST"
- assert route.path == "/products"
-
- # Check request body
- assert route.request_body is not None
- assert route.request_body.required is True
- assert "application/json" in route.request_body.content_schema
-
- # Check schema structure with snapshots
- json_schema = route.request_body.content_schema["application/json"]
- assert json_schema == snapshot(
- {
- "required": ["name", "price"],
- "type": "object",
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "price": {"type": "number"},
- "category": {"$ref": "#/$defs/Category"},
- },
- }
- )
-
- combined_schema = _combine_schemas(route)
- assert combined_schema == snapshot(
- {
- "type": "object",
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "price": {"type": "number"},
- "category": {"$ref": "#/$defs/Category"},
- },
- "required": ["name", "price"],
- "$defs": {
- "Category": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- }
- },
- }
- )
-
-
-def test_openapi_31_reference_resolution(openapi_31_with_references: dict[str, Any]):
- """Test that references are correctly resolved in OpenAPI 3.1 schemas."""
- routes = parse_openapi_to_http_routes(openapi_31_with_references)
-
- assert len(routes) == 1
- route = routes[0]
- assert route.method == "POST"
- assert route.path == "/products"
-
- # Check request body
- assert route.request_body is not None
- assert route.request_body.required is True
- assert "application/json" in route.request_body.content_schema
-
- # Check schema structure
- json_schema = route.request_body.content_schema["application/json"]
- assert json_schema == snapshot(
- {
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "price": {"type": "number"},
- "category": {"$ref": "#/$defs/Category"},
- },
- "type": "object",
- "required": ["name", "price"],
- }
- )
-
- combined_schema = _combine_schemas(route)
- assert combined_schema == snapshot(
- {
- "type": "object",
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "price": {"type": "number"},
- "category": {"$ref": "#/$defs/Category"},
- },
- "required": ["name", "price"],
- "$defs": {
- "Category": {
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- "type": "object",
- }
- },
- }
- )
-
-
-def test_consistent_output_across_versions(
- openapi_30_with_references: dict[str, Any],
- openapi_31_with_references: dict[str, Any],
-):
- """Test that both parsers produce equivalent output for equivalent schemas."""
- routes_30 = parse_openapi_to_http_routes(openapi_30_with_references)
- routes_31 = parse_openapi_to_http_routes(openapi_31_with_references)
-
- # Convert to dict for easier comparison
- route_30_dict = routes_30[0].model_dump(exclude_none=True)
- route_31_dict = routes_31[0].model_dump(exclude_none=True)
-
- # They should be identical except for version-specific differences
- # Compare path
- assert route_30_dict["path"] == route_31_dict["path"]
- # Compare method
- assert route_30_dict["method"] == route_31_dict["method"]
- # Compare operation_id
- assert route_30_dict["operation_id"] == route_31_dict["operation_id"]
- # Compare parameters
- assert len(route_30_dict["parameters"]) == len(route_31_dict["parameters"])
- # Compare request body
- assert (
- route_30_dict["request_body"]["required"]
- == route_31_dict["request_body"]["required"]
- )
- # Compare response structure
- assert "201" in route_30_dict["responses"] and "201" in route_31_dict["responses"]
- # The schemas should contain the same essential fields
- schema_30 = route_30_dict["request_body"]["content_schema"]["application/json"][
- "properties"
- ]
- schema_31 = route_31_dict["request_body"]["content_schema"]["application/json"][
- "properties"
- ]
- assert set(schema_30.keys()) == set(schema_31.keys())
-
-
-class TestReplaceRefWithDefs:
- @pytest.fixture(scope="class")
- def schemas(self):
- """Provide test schemas for _replace_ref_with_defs function."""
- return {
- "ref_type": {
- "$ref": "#/components/schemas/RefFoo",
- },
- "object_type": {
- "type": "object",
- "properties": {"$ref": "#/components/schemas/ObjectFoo"},
- },
- "array_type": {
- "type": "array",
- "items": {"$ref": "#/components/schemas/ArrayFoo"},
- },
- "any_of_type": {
- "anyOf": [
- {"$ref": "#/components/schemas/AnyOfFoo"},
- {"$ref": "#/components/schemas/AnyOfBar"},
- ]
- },
- "all_of_type": {
- "allOf": [
- {"$ref": "#/components/schemas/AllOfFoo"},
- {"$ref": "#/components/schemas/AllOfBar"},
- ]
- },
- "one_of_type": {
- "oneOf": [
- {"$ref": "#/components/schemas/OneOfFoo"},
- {"$ref": "#/components/schemas/OneOfBar"},
- ]
- },
- "nested_type": {
- "type": "object",
- "properties": {
- "pets": {
- "oneOf": [
- {"$ref": "#/components/schemas/Cat"},
- {"$ref": "#/components/schemas/Dog"},
- ]
- },
- },
- },
- }
-
- def test_replace_direct_ref(self, schemas):
- """Test replacing direct $ref references."""
- result = _replace_ref_with_defs(schemas["ref_type"])
- assert result == {"$ref": "#/$defs/RefFoo"}
-
- def test_replace_object_property_ref(self, schemas):
- """Test replacing $ref in object properties."""
- result = _replace_ref_with_defs(schemas["object_type"])
- assert result == {
- "type": "object",
- "properties": {"$ref": "#/$defs/ObjectFoo"},
- }
-
- def test_replace_array_items_ref(self, schemas):
- """Test replacing $ref in array items."""
- result = _replace_ref_with_defs(schemas["array_type"])
- assert result == {
- "type": "array",
- "items": {"$ref": "#/$defs/ArrayFoo"},
- }
-
- def test_replace_any_of_refs(self, schemas):
- """Test replacing $ref in anyOf schemas."""
- result = _replace_ref_with_defs(schemas["any_of_type"])
- assert result == {
- "anyOf": [{"$ref": "#/$defs/AnyOfFoo"}, {"$ref": "#/$defs/AnyOfBar"}]
- }
-
- def test_replace_all_of_refs(self, schemas):
- """Test replacing $ref in allOf schemas."""
- result = _replace_ref_with_defs(schemas["all_of_type"])
- assert result == {
- "allOf": [{"$ref": "#/$defs/AllOfFoo"}, {"$ref": "#/$defs/AllOfBar"}]
- }
-
- def test_replace_one_of_refs(self, schemas):
- """Test replacing $ref in oneOf schemas."""
- result = _replace_ref_with_defs(schemas["one_of_type"])
- assert result == {
- "oneOf": [{"$ref": "#/$defs/OneOfFoo"}, {"$ref": "#/$defs/OneOfBar"}]
- }
-
- def test_replace_nested_refs(self, schemas):
- """Test replacing $ref in deeply nested schema structures."""
- result = _replace_ref_with_defs(schemas["nested_type"])
- assert result == {
- "type": "object",
- "properties": {
- "pets": {"oneOf": [{"$ref": "#/$defs/Cat"}, {"$ref": "#/$defs/Dog"}]}
- },
- }
diff --git a/tests/utilities/openapi/test_openapi_advanced.py b/tests/utilities/openapi/test_openapi_advanced.py
deleted file mode 100644
index 58a03143d..000000000
--- a/tests/utilities/openapi/test_openapi_advanced.py
+++ /dev/null
@@ -1,665 +0,0 @@
-"""Tests for advanced features of the OpenAPI utilities."""
-
-from typing import Any
-
-import pytest
-
-from fastmcp.utilities.openapi import parse_openapi_to_http_routes
-
-
-@pytest.fixture
-def complex_schema() -> dict[str, Any]:
- """Fixture that returns a complex OpenAPI schema with nested references."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Complex API", "version": "1.0.0"},
- "paths": {
- "/users": {
- "get": {
- "summary": "List all users",
- "operationId": "listUsers",
- "parameters": [
- {"$ref": "#/components/parameters/PageLimit"},
- {"$ref": "#/components/parameters/PageOffset"},
- ],
- "responses": {"200": {"description": "A list of users"}},
- }
- },
- "/users/{userId}": {
- "get": {
- "summary": "Get user by ID",
- "operationId": "getUser",
- "parameters": [
- {"$ref": "#/components/parameters/UserId"},
- {"$ref": "#/components/parameters/IncludeInactive"},
- ],
- "responses": {"200": {"description": "User details"}},
- }
- },
- "/users/{userId}/orders": {
- "post": {
- "summary": "Create order for user",
- "operationId": "createOrder",
- "parameters": [{"$ref": "#/components/parameters/UserId"}],
- "requestBody": {"$ref": "#/components/requestBodies/OrderRequest"},
- "responses": {"201": {"description": "Order created"}},
- }
- },
- },
- "components": {
- "parameters": {
- "UserId": {
- "name": "userId",
- "in": "path",
- "required": True,
- "schema": {"type": "string", "format": "uuid"},
- },
- "PageLimit": {
- "name": "limit",
- "in": "query",
- "schema": {"type": "integer", "default": 20, "maximum": 100},
- },
- "PageOffset": {
- "name": "offset",
- "in": "query",
- "schema": {"type": "integer", "default": 0},
- },
- "IncludeInactive": {
- "name": "include_inactive",
- "in": "query",
- "schema": {"type": "boolean", "default": False},
- },
- },
- "schemas": {
- "User": {
- "type": "object",
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "name": {"type": "string"},
- "email": {"type": "string", "format": "email"},
- "role": {"$ref": "#/components/schemas/Role"},
- "address": {"$ref": "#/components/schemas/Address"},
- },
- },
- "Role": {
- "type": "string",
- "enum": ["admin", "user", "guest"],
- },
- "Address": {
- "type": "object",
- "properties": {
- "street": {"type": "string"},
- "city": {"type": "string"},
- "zip": {"type": "string"},
- "country": {"type": "string"},
- },
- },
- "Order": {
- "type": "object",
- "properties": {
- "id": {"type": "string", "format": "uuid"},
- "items": {
- "type": "array",
- "items": {"$ref": "#/components/schemas/OrderItem"},
- },
- "total": {"type": "number"},
- "status": {"$ref": "#/components/schemas/OrderStatus"},
- },
- },
- "OrderItem": {
- "type": "object",
- "properties": {
- "product_id": {"type": "string", "format": "uuid"},
- "quantity": {"type": "integer"},
- "price": {"type": "number"},
- },
- },
- "OrderStatus": {
- "type": "string",
- "enum": [
- "pending",
- "processing",
- "shipped",
- "delivered",
- "cancelled",
- ],
- },
- },
- "requestBodies": {
- "OrderRequest": {
- "description": "Order to create",
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": ["items"],
- "properties": {
- "items": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/OrderItem"
- },
- },
- "notes": {"type": "string"},
- },
- }
- }
- },
- }
- },
- },
- }
-
-
-@pytest.fixture
-def parsed_complex_routes(complex_schema):
- """Return parsed routes from the complex schema."""
- return parse_openapi_to_http_routes(complex_schema)
-
-
-@pytest.fixture
-def complex_route_map(parsed_complex_routes):
- """Return a dictionary of routes by operation ID."""
- return {
- r.operation_id: r for r in parsed_complex_routes if r.operation_id is not None
- }
-
-
-@pytest.fixture
-def schema_with_invalid_reference() -> dict[str, Any]:
- """Fixture that returns a schema with an invalid reference."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Invalid Reference API", "version": "1.0.0"},
- "paths": {
- "/broken-ref": {
- "get": {
- "summary": "Endpoint with broken reference",
- "operationId": "brokenRef",
- "parameters": [
- {"$ref": "#/components/parameters/NonExistentParam"}
- ],
- "responses": {"200": {"description": "Something"}},
- }
- }
- },
- "components": {
- "parameters": {} # Empty parameters object to ensure the reference is broken
- },
- }
-
-
-@pytest.fixture
-def schema_with_content_params() -> dict[str, Any]:
- """Fixture that returns a schema with content-based parameters (complex parameters)."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "Content Params API", "version": "1.0.0"},
- "paths": {
- "/complex-params": {
- "post": {
- "summary": "Endpoint with complex parameter",
- "operationId": "complexParams",
- "parameters": [
- {
- "name": "filter",
- "in": "query",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "field": {"type": "string"},
- "operator": {
- "type": "string",
- "enum": ["eq", "gt", "lt"],
- },
- "value": {"type": "string"},
- },
- }
- }
- },
- }
- ],
- "responses": {"200": {"description": "Results"}},
- }
- },
- },
- }
-
-
-@pytest.fixture
-def parsed_content_param_routes(schema_with_content_params):
- """Return parsed routes from the schema with content parameters."""
- return parse_openapi_to_http_routes(schema_with_content_params)
-
-
-@pytest.fixture
-def schema_all_http_methods() -> dict[str, Any]:
- """Fixture that returns a schema with all HTTP methods."""
- return {
- "openapi": "3.1.0",
- "info": {"title": "All Methods API", "version": "1.0.0"},
- "paths": {
- "/resource": {
- "get": {
- "operationId": "getResource",
- "responses": {"200": {"description": "Success"}},
- },
- "post": {
- "operationId": "createResource",
- "responses": {"201": {"description": "Created"}},
- },
- "put": {
- "operationId": "updateResource",
- "responses": {"200": {"description": "Updated"}},
- },
- "delete": {
- "operationId": "deleteResource",
- "responses": {"204": {"description": "Deleted"}},
- },
- "patch": {
- "operationId": "patchResource",
- "responses": {"200": {"description": "Patched"}},
- },
- "head": {
- "operationId": "headResource",
- "responses": {"200": {"description": "Headers only"}},
- },
- "options": {
- "operationId": "optionsResource",
- "responses": {"200": {"description": "Options"}},
- },
- "trace": {
- "operationId": "traceResource",
- "responses": {"200": {"description": "Trace"}},
- },
- },
- },
- }
-
-
-@pytest.fixture
-def parsed_http_methods_routes(schema_all_http_methods):
- """Return parsed routes from the schema with all HTTP methods."""
- return parse_openapi_to_http_routes(schema_all_http_methods)
-
-
-# --- Tests for complex schemas with references --- #
-
-
-def test_complex_schema_route_count(parsed_complex_routes):
- """Test that parsing a schema with references successfully extracts all routes."""
- assert len(parsed_complex_routes) == 3
-
-
-def test_complex_schema_ref_rewriting(parsed_complex_routes):
- """Test that all #/components references have been rewritten."""
-
- def no_components(value):
- if isinstance(value, dict):
- for k, v in value.items():
- if k == "$ref":
- assert not v.startswith("#/components/"), (
- f"reference '{v}' was not rewritten"
- )
- else:
- no_components(v)
- elif isinstance(value, list):
- for v in value:
- no_components(v)
-
- for route in parsed_complex_routes:
- no_components(route.schema_definitions)
- for param in route.parameters:
- no_components(param.schema_)
-
-
-def test_complex_schema_list_users_query_param_limit(complex_route_map):
- """Test that a reference to a limit query parameter is correctly resolved."""
- list_users = complex_route_map["listUsers"]
-
- limit_param = next((p for p in list_users.parameters if p.name == "limit"), None)
- assert limit_param is not None
- assert limit_param.location == "query"
- assert limit_param.schema_.get("default") == 20
-
-
-def test_complex_schema_list_users_query_param_limit_maximum(complex_route_map):
- """Test that a limit parameter's maximum value is correctly resolved."""
- list_users = complex_route_map["listUsers"]
-
- limit_param = next((p for p in list_users.parameters if p.name == "limit"), None)
- assert limit_param is not None
- assert limit_param.schema_.get("maximum") == 100
-
-
-def test_complex_schema_get_user_path_param_existence(complex_route_map):
- """Test that a reference to a path parameter exists."""
- get_user = complex_route_map["getUser"]
-
- user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None)
- assert user_id_param is not None
- assert user_id_param.location == "path"
-
-
-def test_complex_schema_get_user_path_param_required(complex_route_map):
- """Test that a path parameter is correctly marked as required."""
- get_user = complex_route_map["getUser"]
-
- user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None)
- assert user_id_param is not None
- assert user_id_param.required is True
-
-
-def test_complex_schema_get_user_path_param_format(complex_route_map):
- """Test that a path parameter format is correctly resolved."""
- get_user = complex_route_map["getUser"]
-
- user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None)
- assert user_id_param is not None
- assert user_id_param.schema_.get("format") == "uuid"
-
-
-def test_complex_schema_create_order_request_body_presence(complex_route_map):
- """Test that a reference to a request body is resolved correctly."""
- create_order = complex_route_map["createOrder"]
-
- assert create_order.request_body is not None
- assert create_order.request_body.required is True
-
-
-def test_complex_schema_create_order_request_body_content_type(complex_route_map):
- """Test that request body content type is correctly resolved."""
- create_order = complex_route_map["createOrder"]
-
- assert create_order.request_body is not None
- assert "application/json" in create_order.request_body.content_schema
-
-
-def test_complex_schema_create_order_request_body_properties(complex_route_map):
- """Test that request body properties are correctly resolved."""
- create_order = complex_route_map["createOrder"]
-
- assert create_order.request_body is not None
- json_schema = create_order.request_body.content_schema["application/json"]
- assert "items" in json_schema.get("properties", {})
-
-
-def test_complex_schema_create_order_request_body_required_fields(complex_route_map):
- """Test that request body required fields are correctly resolved."""
- create_order = complex_route_map["createOrder"]
-
- assert create_order.request_body is not None
- json_schema = create_order.request_body.content_schema["application/json"]
- assert json_schema.get("required") == ["items"]
-
-
-# --- Tests for schema reference resolution errors --- #
-
-
-def test_parser_handles_broken_references(schema_with_invalid_reference):
- """Test that parser handles broken references gracefully."""
- # We're just checking that the function doesn't throw an exception
- routes = parse_openapi_to_http_routes(schema_with_invalid_reference)
-
- # Should still return routes list (may be empty)
- assert isinstance(routes, list)
-
- # Verify that the route with broken parameter reference is still included
- # though it may not have the parameter properly
- broken_route = next(
- (r for r in routes if r.path == "/broken-ref" and r.method == "GET"), None
- )
-
- # The route should still be present
- assert broken_route is not None
- assert broken_route.operation_id == "brokenRef"
-
-
-# --- Tests for content-based parameters --- #
-
-
-def test_content_param_parameter_name(parsed_content_param_routes):
- """Test that parser correctly extracts name for content-based parameters."""
- complex_params = parsed_content_param_routes[0]
-
- assert len(complex_params.parameters) == 1
- param = complex_params.parameters[0]
- assert param.name == "filter"
-
-
-def test_content_param_parameter_location(parsed_content_param_routes):
- """Test that parser correctly extracts location for content-based parameters."""
- complex_params = parsed_content_param_routes[0]
-
- assert len(complex_params.parameters) == 1
- param = complex_params.parameters[0]
- assert param.location == "query"
-
-
-def test_content_param_schema_properties_presence(parsed_content_param_routes):
- """Test that parser extracts schema properties from content-based parameter."""
- complex_params = parsed_content_param_routes[0]
-
- param = complex_params.parameters[0]
- properties = param.schema_.get("properties", {})
-
- assert "field" in properties
- assert "operator" in properties
- assert "value" in properties
-
-
-def test_content_param_schema_enum_presence(parsed_content_param_routes):
- """Test that parser extracts enum values from content-based parameter."""
- complex_params = parsed_content_param_routes[0]
-
- param = complex_params.parameters[0]
- properties = param.schema_.get("properties", {})
-
- assert "enum" in properties.get("operator", {})
-
-
-# --- Tests for HTTP methods --- #
-
-
-def test_http_get_method_presence(parsed_http_methods_routes):
- """Test that GET method is correctly extracted."""
- get_route = next((r for r in parsed_http_methods_routes if r.method == "GET"), None)
-
- assert get_route is not None
- assert get_route.operation_id == "getResource"
-
-
-def test_http_get_method_path(parsed_http_methods_routes):
- """Test that GET method path is correctly extracted."""
- get_route = next((r for r in parsed_http_methods_routes if r.method == "GET"), None)
-
- assert get_route is not None
- assert get_route.path == "/resource"
-
-
-def test_http_post_method_presence(parsed_http_methods_routes):
- """Test that POST method is correctly extracted."""
- post_route = next(
- (r for r in parsed_http_methods_routes if r.method == "POST"), None
- )
-
- assert post_route is not None
- assert post_route.operation_id == "createResource"
-
-
-def test_http_post_method_path(parsed_http_methods_routes):
- """Test that POST method path is correctly extracted."""
- post_route = next(
- (r for r in parsed_http_methods_routes if r.method == "POST"), None
- )
-
- assert post_route is not None
- assert post_route.path == "/resource"
-
-
-def test_http_put_method_presence(parsed_http_methods_routes):
- """Test that PUT method is correctly extracted."""
- put_route = next((r for r in parsed_http_methods_routes if r.method == "PUT"), None)
-
- assert put_route is not None
- assert put_route.operation_id == "updateResource"
-
-
-def test_http_put_method_path(parsed_http_methods_routes):
- """Test that PUT method path is correctly extracted."""
- put_route = next((r for r in parsed_http_methods_routes if r.method == "PUT"), None)
-
- assert put_route is not None
- assert put_route.path == "/resource"
-
-
-def test_http_delete_method_presence(parsed_http_methods_routes):
- """Test that DELETE method is correctly extracted."""
- delete_route = next(
- (r for r in parsed_http_methods_routes if r.method == "DELETE"), None
- )
-
- assert delete_route is not None
- assert delete_route.operation_id == "deleteResource"
-
-
-def test_http_delete_method_path(parsed_http_methods_routes):
- """Test that DELETE method path is correctly extracted."""
- delete_route = next(
- (r for r in parsed_http_methods_routes if r.method == "DELETE"), None
- )
-
- assert delete_route is not None
- assert delete_route.path == "/resource"
-
-
-def test_http_patch_method_presence(parsed_http_methods_routes):
- """Test that PATCH method is correctly extracted."""
- patch_route = next(
- (r for r in parsed_http_methods_routes if r.method == "PATCH"), None
- )
-
- assert patch_route is not None
- assert patch_route.operation_id == "patchResource"
-
-
-def test_http_patch_method_path(parsed_http_methods_routes):
- """Test that PATCH method path is correctly extracted."""
- patch_route = next(
- (r for r in parsed_http_methods_routes if r.method == "PATCH"), None
- )
-
- assert patch_route is not None
- assert patch_route.path == "/resource"
-
-
-def test_http_head_method_presence(parsed_http_methods_routes):
- """Test that HEAD method is correctly extracted."""
- head_route = next(
- (r for r in parsed_http_methods_routes if r.method == "HEAD"), None
- )
-
- assert head_route is not None
- assert head_route.operation_id == "headResource"
-
-
-def test_http_head_method_path(parsed_http_methods_routes):
- """Test that HEAD method path is correctly extracted."""
- head_route = next(
- (r for r in parsed_http_methods_routes if r.method == "HEAD"), None
- )
-
- assert head_route is not None
- assert head_route.path == "/resource"
-
-
-def test_http_options_method_presence(parsed_http_methods_routes):
- """Test that OPTIONS method is correctly extracted."""
- options_route = next(
- (r for r in parsed_http_methods_routes if r.method == "OPTIONS"), None
- )
-
- assert options_route is not None
- assert options_route.operation_id == "optionsResource"
-
-
-def test_http_options_method_path(parsed_http_methods_routes):
- """Test that OPTIONS method path is correctly extracted."""
- options_route = next(
- (r for r in parsed_http_methods_routes if r.method == "OPTIONS"), None
- )
-
- assert options_route is not None
- assert options_route.path == "/resource"
-
-
-def test_http_trace_method_presence(parsed_http_methods_routes):
- """Test that TRACE method is correctly extracted."""
- trace_route = next(
- (r for r in parsed_http_methods_routes if r.method == "TRACE"), None
- )
-
- assert trace_route is not None
- assert trace_route.operation_id == "traceResource"
-
-
-def test_http_trace_method_path(parsed_http_methods_routes):
- """Test that TRACE method path is correctly extracted."""
- trace_route = next(
- (r for r in parsed_http_methods_routes if r.method == "TRACE"), None
- )
-
- assert trace_route is not None
- assert trace_route.path == "/resource"
-
-
-@pytest.fixture
-def schema_with_external_reference() -> dict[str, Any]:
- """Fixture that returns a schema with external schema references like in issue #926."""
- return {
- "openapi": "3.0.0",
- "info": {"title": "External Reference API", "version": "1.0.0"},
- "paths": {
- "/products": {
- "post": {
- "summary": "Create a product",
- "operationId": "createProduct",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "obj": {
- "$ref": "http://cyaninc.com/json-schemas/market-v1/product-constraints"
- }
- },
- }
- }
- },
- },
- "responses": {"201": {"description": "Product created"}},
- }
- }
- },
- }
-
-
-# --- Tests for external schema reference handling --- #
-
-
-def test_external_reference_raises_clear_error(schema_with_external_reference):
- """Test that external schema references raise a clear, helpful error message."""
- with pytest.raises(ValueError) as exc_info:
- parse_openapi_to_http_routes(schema_with_external_reference)
-
- error_message = str(exc_info.value)
- assert "External or non-local reference not supported" in error_message
- assert (
- "http://cyaninc.com/json-schemas/market-v1/product-constraints" in error_message
- )
- assert "FastMCP only supports local schema references" in error_message
diff --git a/tests/utilities/openapi/test_openapi_fastapi.py b/tests/utilities/openapi/test_openapi_fastapi.py
deleted file mode 100644
index 7df179aa0..000000000
--- a/tests/utilities/openapi/test_openapi_fastapi.py
+++ /dev/null
@@ -1,540 +0,0 @@
-"""Tests for FastAPI integration with the OpenAPI utilities."""
-
-from typing import Any
-
-import pytest
-from fastapi import FastAPI
-
-from fastmcp.utilities.openapi import parse_openapi_to_http_routes
-
-
-@pytest.fixture
-def fastapi_app() -> FastAPI:
- """Fixture that returns a FastAPI app for live OpenAPI schema testing."""
- from enum import Enum
-
- from fastapi import Body, Depends, Header, HTTPException, Path, Query
- from pydantic import BaseModel, Field
-
- class ItemStatus(str, Enum):
- available = "available"
- pending = "pending"
- sold = "sold"
-
- class Tag(BaseModel):
- id: int
- name: str
-
- class Item(BaseModel):
- """Example pydantic model for testing OpenAPI schema generation."""
-
- name: str
- description: str | None = None
- price: float
- tax: float | None = None
- tags: list[str] = Field(default_factory=list)
- status: ItemStatus = ItemStatus.available
- dimensions: dict[str, float] | None = None
-
- # Create a FastAPI app with comprehensive features
- app = FastAPI(
- title="Comprehensive Test API",
- description="A test API with various OpenAPI features",
- version="1.0.0",
- )
-
- def get_token_header(
- x_token: str = Header(..., description="Authentication token"),
- ):
- """Example dependency function for header validation."""
- if x_token != "fake-super-secret-token":
- raise HTTPException(status_code=400, detail="X-Token header invalid")
- return x_token
-
- TokenDep = Depends(get_token_header)
-
- @app.get(
- "/items/",
- operation_id="list_items",
- summary="List all items",
- description="Get a list of all items with optional filtering",
- tags=["items"],
- )
- async def list_items(
- skip: int = Query(0, description="Number of items to skip"),
- limit: int = Query(10, description="Max number of items to return"),
- status: ItemStatus | None = Query(None, description="Filter items by status"),
- ):
- """List all items with pagination and optional status filtering."""
- fake_items = [
- {"name": f"Item {i}", "price": float(i)} for i in range(skip, skip + limit)
- ]
- if status:
- fake_items = [item for item in fake_items if item.get("status") == status]
- return fake_items
-
- @app.post(
- "/items/",
- operation_id="create_item",
- summary="Create a new item",
- tags=["items"],
- status_code=201,
- )
- async def create_item(
- item: Item = Body(..., description="Item to create"),
- x_token: str = TokenDep,
- ):
- """Create a new item (requires authentication)."""
- return item
-
- @app.get(
- "/items/{item_id}",
- operation_id="get_item",
- summary="Get a specific item by ID",
- tags=["items"],
- )
- async def get_item(
- item_id: int = Path(..., description="The ID of the item to retrieve"),
- include_tax: bool = Query(
- False, description="Whether to include tax information"
- ),
- ):
- """Get details about a specific item."""
- price = float(item_id) * 10.0
- item = {
- "id": item_id,
- "name": f"Item {item_id}",
- "price": price,
- }
- if include_tax:
- item["tax"] = price * 0.2
- return item
-
- @app.put(
- "/items/{item_id}",
- operation_id="update_item",
- summary="Update an existing item",
- tags=["items"],
- )
- async def update_item(
- item_id: int = Path(..., description="The ID of the item to update"),
- item: Item = Body(..., description="Updated item data"),
- x_token: str = TokenDep,
- ):
- """Update an existing item (requires authentication)."""
- return {"item_id": item_id, **item.model_dump()}
-
- @app.delete(
- "/items/{item_id}",
- operation_id="delete_item",
- summary="Delete an item",
- tags=["items"],
- )
- async def delete_item(
- item_id: int = Path(..., description="The ID of the item to delete"),
- x_token: str = TokenDep,
- ):
- """Delete an item (requires authentication)."""
- return {"item_id": item_id, "deleted": True}
-
- @app.patch(
- "/items/{item_id}/tags",
- operation_id="update_item_tags",
- summary="Update item tags",
- tags=["items", "tags"],
- )
- async def update_item_tags(
- item_id: int = Path(..., description="The ID of the item"),
- tags: list[str] = Body(..., description="Updated tags"),
- ):
- """Update just the tags of an item."""
- return {"item_id": item_id, "tags": tags}
-
- @app.get(
- "/items/{item_id}/tags/{tag_id}",
- operation_id="get_item_tag",
- summary="Get a specific tag for an item",
- tags=["items", "tags"],
- )
- async def get_item_tag(
- item_id: int = Path(..., description="The ID of the item"),
- tag_id: str = Path(..., description="The ID of the tag"),
- ):
- """Get a specific tag for an item."""
- return {"item_id": item_id, "tag_id": tag_id}
-
- @app.post(
- "/upload/",
- operation_id="upload_file",
- summary="Upload a file",
- tags=["files"],
- )
- async def upload_file(
- file_name: str = Query(..., description="Name of the file"),
- content_type: str = Query(..., description="Content type of the file"),
- ):
- """Upload a file (dummy endpoint for testing query params)."""
- return {
- "file_name": file_name,
- "content_type": content_type,
- "status": "uploaded",
- }
-
- # Add a callback route for testing complex documentation
- @app.post(
- "/webhook",
- operation_id="register_webhook",
- summary="Register a webhook",
- tags=["webhooks"],
- callbacks={ # type: ignore
- "itemProcessed": {
- "{$request.body.callbackUrl}": {
- "post": {
- "summary": "Callback for when an item is processed",
- "requestBody": {
- "required": True,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "item_id": {"type": "integer"},
- "status": {"type": "string"},
- "timestamp": {
- "type": "string",
- "format": "date-time",
- },
- },
- }
- }
- },
- },
- "responses": {
- "200": {"description": "Webhook processed successfully"}
- },
- }
- }
- }
- },
- )
- async def register_webhook(
- callback_url: str = Body(
- ..., embed=True, description="URL to call when processing completes"
- ),
- ):
- """Register a webhook for processing notifications."""
- return {"registered": True, "callback_url": callback_url}
-
- return app
-
-
-@pytest.fixture
-def fastapi_openapi_schema(fastapi_app) -> dict[str, Any]:
- """Fixture that returns the OpenAPI schema from a live FastAPI server."""
- return fastapi_app.openapi()
-
-
-@pytest.fixture
-def parsed_routes(fastapi_openapi_schema):
- """Return parsed routes from a FastAPI OpenAPI schema."""
- return parse_openapi_to_http_routes(fastapi_openapi_schema)
-
-
-@pytest.fixture
-def route_map(parsed_routes):
- """Return a dictionary of routes by operation ID."""
- return {r.operation_id: r for r in parsed_routes if r.operation_id is not None}
-
-
-def test_parse_fastapi_schema_route_count(parsed_routes):
- """Test that all routes are parsed from the FastAPI schema."""
- assert len(parsed_routes) == 9 # 8 endpoints + 1 callback
-
-
-def test_parse_fastapi_schema_operation_ids(route_map):
- """Test that all expected operation IDs are present in the parsed schema."""
- expected_operations = [
- "list_items",
- "create_item",
- "get_item",
- "update_item",
- "delete_item",
- "update_item_tags",
- "get_item_tag",
- "upload_file",
- "register_webhook",
- ]
-
- for op_id in expected_operations:
- assert op_id in route_map, f"Operation ID '{op_id}' not found in parsed routes"
-
-
-def test_path_parameter_parsing(route_map):
- """Test that path parameters are correctly parsed."""
- get_item = route_map["get_item"]
- path_params = [p for p in get_item.parameters if p.location == "path"]
-
- assert len(path_params) == 1
- assert path_params[0].name == "item_id"
- assert path_params[0].required is True
-
-
-def test_query_parameter_parsing(route_map):
- """Test that query parameters are correctly parsed."""
- list_items = route_map["list_items"]
- query_params = [p for p in list_items.parameters if p.location == "query"]
-
- assert len(query_params) == 3 # skip, limit, status
- param_names = [p.name for p in query_params]
- assert "skip" in param_names
- assert "limit" in param_names
- assert "status" in param_names
-
-
-def test_header_parameter_parsing(route_map):
- """Test that header parameters from dependencies are correctly parsed."""
- create_item = route_map["create_item"]
- header_params = [p for p in create_item.parameters if p.location == "header"]
-
- assert len(header_params) == 1
- assert header_params[0].name == "x-token"
- assert header_params[0].required is True
-
-
-def test_request_body_content_type(route_map):
- """Test that request body content types are correctly parsed."""
- create_item = route_map["create_item"]
-
- assert create_item.request_body is not None
- assert "application/json" in create_item.request_body.content_schema
-
-
-def test_request_body_properties(route_map):
- """Test that request body properties are correctly parsed."""
- create_item = route_map["create_item"]
- json_schema = create_item.request_body.content_schema["application/json"]
- properties = json_schema.get("properties", {})
-
- assert "name" in properties
- assert "price" in properties
- assert "description" in properties
- assert "tags" in properties
- assert "status" in properties
-
-
-def test_request_body_status_schema(route_map):
- """Test that the status schema in request body is correctly handled."""
- create_item = route_map["create_item"]
- json_schema = create_item.request_body.content_schema["application/json"]
- properties = json_schema.get("properties", {})
- status_schema = properties.get("status", {})
-
- # FastAPI may represent enums as references or directly include enum values
- assert "$ref" in status_schema or "enum" in status_schema
-
-
-def test_route_with_items_tag(parsed_routes):
- """Test that routes with 'items' tag are correctly parsed."""
- item_routes = [r for r in parsed_routes if "items" in r.tags]
-
- assert len(item_routes) >= 6 # At least 6 endpoints with "items" tag
-
-
-def test_routes_with_multiple_tags(parsed_routes):
- """Test that routes with multiple tags are correctly parsed."""
- multi_tag_routes = [r for r in parsed_routes if len(r.tags) > 1]
-
- assert len(multi_tag_routes) >= 2 # At least 2 endpoints with multiple tags
-
-
-def test_specific_route_tags(route_map):
- """Test that specific routes have the expected tags."""
- assert "items" in route_map["list_items"].tags
- assert "items" in route_map["update_item_tags"].tags
- assert "tags" in route_map["update_item_tags"].tags
- assert "webhooks" in route_map["register_webhook"].tags
-
-
-def test_operation_summary(route_map):
- """Test that operation summary is correctly parsed."""
- list_items = route_map["list_items"]
-
- assert list_items.summary == "List all items"
-
-
-def test_operation_description(route_map):
- """Test that operation description is correctly parsed."""
- list_items = route_map["list_items"]
-
- assert list_items.description is not None
- assert "optional filtering" in list_items.description
-
-
-def test_path_with_route_parameters(route_map):
- """Test that paths with route parameters are correctly parsed."""
- get_item = route_map["get_item"]
-
- assert get_item.path == "/items/{item_id}"
-
-
-def test_complex_nested_paths(route_map):
- """Test that complex nested paths are correctly parsed."""
- get_item_tag = route_map["get_item_tag"]
-
- assert get_item_tag.path == "/items/{item_id}/tags/{tag_id}"
-
-
-def test_http_methods(route_map):
- """Test that HTTP methods are correctly parsed."""
- assert route_map["list_items"].method == "GET"
- assert route_map["create_item"].method == "POST"
- assert route_map["update_item"].method == "PUT"
- assert route_map["delete_item"].method == "DELETE"
- assert route_map["update_item_tags"].method == "PATCH"
-
-
-def test_item_schema_properties(route_map):
- """Test that Item schema properties are correctly resolved."""
- create_item = route_map["create_item"]
- json_schema = create_item.request_body.content_schema["application/json"]
- properties = json_schema.get("properties", {})
-
- assert "name" in properties
- assert properties["name"]["type"] == "string"
- assert "price" in properties
- assert properties["price"]["type"] == "number"
-
-
-def test_webhook_endpoint(route_map):
- """Test parsing of webhook registration endpoint."""
- webhook = route_map["register_webhook"]
-
- assert webhook.method == "POST"
- assert webhook.path == "/webhook"
-
-
-def test_webhook_request_body(route_map):
- """Test that webhook request body is correctly parsed."""
- webhook = route_map["register_webhook"]
-
- assert webhook.request_body is not None
- assert "application/json" in webhook.request_body.content_schema
- json_schema = webhook.request_body.content_schema["application/json"]
- assert "callback_url" in json_schema.get("properties", {})
-
-
-def test_token_dependency_handling(route_map):
- """Test that token dependencies are correctly handled in parsed endpoints."""
- token_endpoints = ["create_item", "update_item", "delete_item"]
-
- for op_id in token_endpoints:
- route = route_map[op_id]
- header_params = [p for p in route.parameters if p.location == "header"]
- token_headers = [p for p in header_params if p.name == "x-token"]
- assert len(token_headers) == 1, f"Expected x-token header in {op_id}"
- assert token_headers[0].required is True
-
-
-# --- Additional Tag-related Tests --- #
-
-
-def test_all_routes_have_tags(parsed_routes):
- """Test that all routes have a non-empty tags list."""
- for route in parsed_routes:
- assert hasattr(route, "tags"), f"Route {route.path} should have tags attribute"
- assert route.tags is not None, f"Route {route.path} tags should not be None"
- # FastAPI adds tags to all routes in our test fixture
- assert len(route.tags) > 0, f"Route {route.path} should have at least one tag"
-
-
-def test_tag_consistency_across_related_endpoints(route_map):
- """Test that related endpoints have consistent tags."""
- # All item endpoints should have the "items" tag
- item_endpoints = [
- "list_items",
- "create_item",
- "get_item",
- "update_item",
- "delete_item",
- ]
- for endpoint in item_endpoints:
- assert "items" in route_map[endpoint].tags, (
- f"Endpoint {endpoint} should have 'items' tag"
- )
-
- # Tag-related endpoints should have both "items" and "tags" tags
- tag_endpoints = ["update_item_tags", "get_item_tag"]
- for endpoint in tag_endpoints:
- assert "items" in route_map[endpoint].tags, (
- f"Endpoint {endpoint} should have 'items' tag"
- )
- assert "tags" in route_map[endpoint].tags, (
- f"Endpoint {endpoint} should have 'tags' tag"
- )
-
-
-def test_tag_order_preservation(fastapi_app):
- """Test that tag order is preserved in the parsed routes."""
-
- # Add a new endpoint with specifically ordered tags
- @fastapi_app.get(
- "/test-tag-order",
- tags=["first", "second", "third"],
- operation_id="test_tag_order",
- )
- async def test_tag_order():
- return {"result": "testing tag order"}
-
- # Get the updated schema and parse routes
- routes = parse_openapi_to_http_routes(fastapi_app.openapi())
-
- # Find our test route
- test_route = next((r for r in routes if r.path == "/test-tag-order"), None)
- assert test_route is not None
-
- # Check tag order is preserved
- assert test_route.tags == ["first", "second", "third"], (
- "Tag order should be preserved"
- )
-
-
-def test_duplicate_tags_handling(fastapi_app):
- """Test handling of duplicate tags in the OpenAPI schema."""
-
- # Add an endpoint with duplicate tags
- @fastapi_app.get(
- "/test-duplicate-tags",
- tags=["duplicate", "items", "duplicate"],
- operation_id="test_duplicate_tags",
- )
- async def test_duplicate_tags():
- return {"result": "testing duplicate tags"}
-
- # Get the updated schema and parse routes
- routes = parse_openapi_to_http_routes(fastapi_app.openapi())
-
- # Find our test route
- test_route = next((r for r in routes if r.path == "/test-duplicate-tags"), None)
- assert test_route is not None
-
- # Check that duplicate tags are preserved (FastAPI might deduplicate)
- # We'll test both possibilities to be safe
- assert "duplicate" in test_route.tags, "Tag 'duplicate' should be present"
- assert "items" in test_route.tags, "Tag 'items' should be present"
-
-
-def test_repr_http_routes(parsed_routes):
- """Test that HTTPRoute objects can be represented without recursion errors."""
- # Test repr on all parsed routes
- for route in parsed_routes:
- route_repr = repr(route)
-
- # Verify repr contains essential information
- assert route.method in route_repr, f"Method {route.method} missing from repr"
- assert route.path in route_repr, f"Path {route.path} missing from repr"
-
- # If operation_id exists, it should be in the repr
- if route.operation_id:
- assert route.operation_id in route_repr, (
- f"Operation ID {route.operation_id} missing from repr"
- )
diff --git a/tests/utilities/openapi/test_openapi_output_schemas.py b/tests/utilities/openapi/test_openapi_output_schemas.py
deleted file mode 100644
index cbe6fd074..000000000
--- a/tests/utilities/openapi/test_openapi_output_schemas.py
+++ /dev/null
@@ -1,276 +0,0 @@
-"""Tests for OpenAPI output schema extraction functionality."""
-
-from fastmcp.utilities.openapi import (
- ResponseInfo,
- _adjust_union_types,
- extract_output_schema_from_responses,
-)
-
-
-class TestExtractOutputSchema:
- """Test the extract_output_schema_from_responses function."""
-
- def test_extract_object_schema(self):
- """Test extracting object output schema (no wrapping needed)."""
- responses = {
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- "required": ["id", "name"],
- }
- },
- )
- }
-
- result = extract_output_schema_from_responses(responses)
-
- assert result == {
- "type": "object",
- "properties": {"id": {"type": "integer"}, "name": {"type": "string"}},
- "required": ["id", "name"],
- }
- assert result is not None and "x-fastmcp-wrap-result" not in result
-
- def test_extract_array_schema_with_wrapping(self):
- """Test extracting array output schema (should be wrapped)."""
- responses = {
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "application/json": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- },
- }
- },
- )
- }
-
- result = extract_output_schema_from_responses(responses)
-
- assert result == {
- "type": "object",
- "properties": {
- "result": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {"type": "integer"},
- "name": {"type": "string"},
- },
- },
- }
- },
- "required": ["result"],
- "x-fastmcp-wrap-result": True,
- }
-
- def test_extract_primitive_schema_with_wrapping(self):
- """Test extracting primitive output schema (should be wrapped)."""
- responses = {
- "201": ResponseInfo(
- description="Created",
- content_schema={
- "application/json": {
- "type": "string",
- "description": "ID of created resource",
- }
- },
- )
- }
-
- result = extract_output_schema_from_responses(responses)
-
- assert result == {
- "type": "object",
- "properties": {
- "result": {"type": "string", "description": "ID of created resource"}
- },
- "required": ["result"],
- "x-fastmcp-wrap-result": True,
- }
-
- def test_priority_of_success_codes(self):
- """Test that 200 takes priority over other success codes."""
- responses = {
- "201": ResponseInfo(
- description="Created",
- content_schema={"application/json": {"type": "string"}},
- ),
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {"id": {"type": "integer"}},
- }
- },
- ),
- }
-
- result = extract_output_schema_from_responses(responses)
-
- # Should use the 200 response (object), not 201 (string)
- assert result is not None and result["type"] == "object"
- assert result is not None and "x-fastmcp-wrap-result" not in result
-
- def test_prefer_json_content_type(self):
- """Test that application/json is preferred over other content types."""
- responses = {
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "text/plain": {"type": "string"},
- "application/json": {
- "type": "object",
- "properties": {"id": {"type": "integer"}},
- },
- },
- )
- }
-
- result = extract_output_schema_from_responses(responses)
-
- # Should use the application/json schema (object), not text/plain (string)
- assert result is not None and result["type"] == "object"
- assert result is not None and "x-fastmcp-wrap-result" not in result
-
- def test_no_responses(self):
- """Test that None is returned when no responses are provided."""
- result = extract_output_schema_from_responses({})
- assert result is None
-
- def test_no_success_responses(self):
- """Test that None is returned when no success responses are found."""
- responses = {
- "400": ResponseInfo(
- description="Bad Request",
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {"error": {"type": "string"}},
- }
- },
- )
- }
-
- result = extract_output_schema_from_responses(responses)
- assert result is None
-
- def test_no_content_schema(self):
- """Test that None is returned when response has no content schema."""
- responses = {"204": ResponseInfo(description="No Content")}
-
- result = extract_output_schema_from_responses(responses)
- assert result is None
-
- def test_schema_definitions_included(self):
- """Test that schema definitions are properly included in output schema."""
- responses = {
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {"user": {"$ref": "#/$defs/User"}},
- }
- },
- )
- }
-
- schema_definitions = {
- "User": {
- "type": "object",
- "properties": {"id": {"type": "integer"}, "name": {"type": "string"}},
- "required": ["id", "name"],
- }
- }
-
- result = extract_output_schema_from_responses(responses, schema_definitions)
-
- assert result is not None
- assert "$defs" in result
- assert "User" in result["$defs"]
- assert result["$defs"]["User"] == schema_definitions["User"]
-
- def test_wrapped_schema_with_definitions(self):
- """Test that wrapped schemas properly include schema definitions."""
- responses = {
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "application/json": {
- "type": "array",
- "items": {"$ref": "#/$defs/User"},
- }
- },
- )
- }
-
- schema_definitions = {
- "User": {
- "type": "object",
- "properties": {"id": {"type": "integer"}, "name": {"type": "string"}},
- "required": ["id", "name"],
- }
- }
-
- result = extract_output_schema_from_responses(responses, schema_definitions)
-
- assert result is not None
- assert result["x-fastmcp-wrap-result"] is True
- assert "$defs" in result
- assert "User" in result["$defs"]
- assert result["properties"]["result"]["type"] == "array"
- assert result["properties"]["result"]["items"]["$ref"] == "#/$defs/User"
-
-
-def test_adjust_union_types():
- """Test that oneOf is replaced with anyOf in schemas."""
- schema = {"oneOf": [{"type": "string"}, {"type": "number"}]}
- result = _adjust_union_types(schema)
- assert isinstance(result, dict)
- assert "anyOf" in result
- assert "oneOf" not in result
- assert len(result["anyOf"]) == 2
- assert result["anyOf"][0] == {"type": "string"}
- assert result["anyOf"][1] == {"type": "number"}
-
-
-def test_extract_output_schema_converts_oneOf_to_anyOf():
- """Test that extracted schema converts oneOf to anyOf."""
- responses = {
- "200": ResponseInfo(
- description="Success",
- content_schema={
- "application/json": {
- "type": "object",
- "properties": {
- "result": {
- "oneOf": [
- {"$ref": "#/$defs/TypeA"},
- {"$ref": "#/$defs/TypeB"},
- ]
- }
- },
- }
- },
- )
- }
- schema_definitions = {"TypeA": {"type": "string"}, "TypeB": {"type": "number"}}
- result = extract_output_schema_from_responses(responses, schema_definitions)
- assert result is not None
- assert "oneOf" not in str(result) # Ensure no oneOf remains
- assert "anyOf" in str(result) # Ensure anyOf is present
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_parser.py b/tests/utilities/openapi/test_parser.py
similarity index 99%
rename from tests/experimental/openapi_parser/utilities/openapi/test_parser.py
rename to tests/utilities/openapi/test_parser.py
index a4a66f8a6..a281c710b 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_parser.py
+++ b/tests/utilities/openapi/test_parser.py
@@ -2,7 +2,7 @@
import pytest
-from fastmcp.experimental.utilities.openapi.parser import parse_openapi_to_http_routes
+from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
class TestOpenAPIParser:
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_schemas.py b/tests/utilities/openapi/test_schemas.py
similarity index 99%
rename from tests/experimental/openapi_parser/utilities/openapi/test_schemas.py
rename to tests/utilities/openapi/test_schemas.py
index 1f4966d6e..eebcaecc2 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_schemas.py
+++ b/tests/utilities/openapi/test_schemas.py
@@ -2,17 +2,17 @@
import pytest
-from fastmcp.experimental.utilities.openapi.models import (
+from fastmcp.utilities.json_schema import compress_schema
+from fastmcp.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
RequestBodyInfo,
)
-from fastmcp.experimental.utilities.openapi.schemas import (
+from fastmcp.utilities.openapi.schemas import (
_combine_schemas,
_combine_schemas_and_map_params,
_replace_ref_with_defs,
)
-from fastmcp.utilities.json_schema import compress_schema
class TestSchemaProcessing:
diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py b/tests/utilities/openapi/test_transitive_references.py
similarity index 99%
rename from tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py
rename to tests/utilities/openapi/test_transitive_references.py
index e3cae4ae1..fbb15f597 100644
--- a/tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py
+++ b/tests/utilities/openapi/test_transitive_references.py
@@ -1,13 +1,13 @@
"""Comprehensive tests for transitive and nested reference handling (Issue #1372)."""
-from fastmcp.experimental.utilities.openapi.models import (
+from fastmcp.utilities.openapi.models import (
HTTPRoute,
ParameterInfo,
RequestBodyInfo,
ResponseInfo,
)
-from fastmcp.experimental.utilities.openapi.parser import parse_openapi_to_http_routes
-from fastmcp.experimental.utilities.openapi.schemas import (
+from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes
+from fastmcp.utilities.openapi.schemas import (
_combine_schemas_and_map_params,
extract_output_schema_from_responses,
)