Spaces:
Running
Running
Merge pull request #153 from jlowin/openapi-mount
Browse filesEnsure objects are copied properly and test mounting fastapi
- src/fastmcp/prompts/prompt.py +0 -8
- src/fastmcp/prompts/prompt_manager.py +3 -1
- src/fastmcp/resources/resource.py +1 -9
- src/fastmcp/resources/resource_manager.py +5 -4
- src/fastmcp/resources/template.py +0 -8
- src/fastmcp/tools/tool.py +0 -8
- src/fastmcp/tools/tool_manager.py +4 -1
- src/fastmcp/utilities/openapi.py +0 -87
- tests/server/test_openapi.py +392 -28
- uv.lock +2 -2
src/fastmcp/prompts/prompt.py
CHANGED
|
@@ -8,7 +8,6 @@ from typing import Annotated, Any, Literal
|
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
| 10 |
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
| 11 |
-
from typing_extensions import Self
|
| 12 |
|
| 13 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 14 |
|
|
@@ -163,13 +162,6 @@ class Prompt(BaseModel):
|
|
| 163 |
except Exception as e:
|
| 164 |
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
| 165 |
|
| 166 |
-
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 167 |
-
"""Copy the prompt with optional updates."""
|
| 168 |
-
data = self.model_dump()
|
| 169 |
-
if updates:
|
| 170 |
-
data.update(updates)
|
| 171 |
-
return type(self)(**data)
|
| 172 |
-
|
| 173 |
def __eq__(self, other: object) -> bool:
|
| 174 |
if not isinstance(other, Prompt):
|
| 175 |
return False
|
|
|
|
| 8 |
import pydantic_core
|
| 9 |
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
| 10 |
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
|
|
|
| 11 |
|
| 12 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 13 |
|
|
|
|
| 162 |
except Exception as e:
|
| 163 |
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
def __eq__(self, other: object) -> bool:
|
| 166 |
if not isinstance(other, Prompt):
|
| 167 |
return False
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Prompt management functionality."""
|
| 2 |
|
|
|
|
| 3 |
from collections.abc import Awaitable, Callable
|
| 4 |
from typing import Any
|
| 5 |
|
|
@@ -84,7 +85,8 @@ class PromptManager:
|
|
| 84 |
# Create prefixed name
|
| 85 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 86 |
|
| 87 |
-
new_prompt =
|
|
|
|
| 88 |
|
| 89 |
# Store the prompt with the prefixed name
|
| 90 |
self.add_prompt(new_prompt)
|
|
|
|
| 1 |
"""Prompt management functionality."""
|
| 2 |
|
| 3 |
+
import copy
|
| 4 |
from collections.abc import Awaitable, Callable
|
| 5 |
from typing import Any
|
| 6 |
|
|
|
|
| 85 |
# Create prefixed name
|
| 86 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 87 |
|
| 88 |
+
new_prompt = copy.copy(prompt)
|
| 89 |
+
new_prompt.name = prefixed_name
|
| 90 |
|
| 91 |
# Store the prompt with the prefixed name
|
| 92 |
self.add_prompt(new_prompt)
|
src/fastmcp/resources/resource.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Base classes and interfaces for FastMCP resources."""
|
| 2 |
|
| 3 |
import abc
|
| 4 |
-
from typing import Annotated
|
| 5 |
|
| 6 |
from pydantic import (
|
| 7 |
AnyUrl,
|
|
@@ -13,7 +13,6 @@ from pydantic import (
|
|
| 13 |
ValidationInfo,
|
| 14 |
field_validator,
|
| 15 |
)
|
| 16 |
-
from typing_extensions import Self
|
| 17 |
|
| 18 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 19 |
|
|
@@ -54,13 +53,6 @@ class Resource(BaseModel, abc.ABC):
|
|
| 54 |
"""Read the resource content."""
|
| 55 |
pass
|
| 56 |
|
| 57 |
-
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 58 |
-
"""Copy the resource with optional updates."""
|
| 59 |
-
data = self.model_dump()
|
| 60 |
-
if updates:
|
| 61 |
-
data.update(updates)
|
| 62 |
-
return type(self)(**data)
|
| 63 |
-
|
| 64 |
def __eq__(self, other: object) -> bool:
|
| 65 |
if not isinstance(other, Resource):
|
| 66 |
return False
|
|
|
|
| 1 |
"""Base classes and interfaces for FastMCP resources."""
|
| 2 |
|
| 3 |
import abc
|
| 4 |
+
from typing import Annotated
|
| 5 |
|
| 6 |
from pydantic import (
|
| 7 |
AnyUrl,
|
|
|
|
| 13 |
ValidationInfo,
|
| 14 |
field_validator,
|
| 15 |
)
|
|
|
|
| 16 |
|
| 17 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 18 |
|
|
|
|
| 53 |
"""Read the resource content."""
|
| 54 |
pass
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
def __eq__(self, other: object) -> bool:
|
| 57 |
if not isinstance(other, Resource):
|
| 58 |
return False
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Resource manager functionality."""
|
| 2 |
|
|
|
|
| 3 |
import inspect
|
| 4 |
import re
|
| 5 |
from collections.abc import Callable
|
|
@@ -238,7 +239,8 @@ class ResourceManager:
|
|
| 238 |
# Create prefixed URI and copy the resource with the new URI
|
| 239 |
prefixed_uri = f"{prefix}{uri}" if prefix else uri
|
| 240 |
|
| 241 |
-
new_resource =
|
|
|
|
| 242 |
|
| 243 |
# Store directly in resources dictionary
|
| 244 |
self.add_resource(new_resource)
|
|
@@ -266,9 +268,8 @@ class ResourceManager:
|
|
| 266 |
f"{prefix}{uri_template}" if prefix else uri_template
|
| 267 |
)
|
| 268 |
|
| 269 |
-
new_template =
|
| 270 |
-
|
| 271 |
-
)
|
| 272 |
|
| 273 |
# Store directly in templates dictionary
|
| 274 |
self.add_template(new_template)
|
|
|
|
| 1 |
"""Resource manager functionality."""
|
| 2 |
|
| 3 |
+
import copy
|
| 4 |
import inspect
|
| 5 |
import re
|
| 6 |
from collections.abc import Callable
|
|
|
|
| 239 |
# Create prefixed URI and copy the resource with the new URI
|
| 240 |
prefixed_uri = f"{prefix}{uri}" if prefix else uri
|
| 241 |
|
| 242 |
+
new_resource = copy.copy(resource)
|
| 243 |
+
new_resource.uri = AnyUrl(prefixed_uri)
|
| 244 |
|
| 245 |
# Store directly in resources dictionary
|
| 246 |
self.add_resource(new_resource)
|
|
|
|
| 268 |
f"{prefix}{uri_template}" if prefix else uri_template
|
| 269 |
)
|
| 270 |
|
| 271 |
+
new_template = copy.copy(template)
|
| 272 |
+
new_template.uri_template = prefixed_uri_template
|
|
|
|
| 273 |
|
| 274 |
# Store directly in templates dictionary
|
| 275 |
self.add_template(new_template)
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -8,7 +8,6 @@ from collections.abc import Callable
|
|
| 8 |
from typing import Annotated, Any
|
| 9 |
|
| 10 |
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
| 11 |
-
from typing_extensions import Self
|
| 12 |
|
| 13 |
from fastmcp.resources.types import FunctionResource, Resource
|
| 14 |
from fastmcp.utilities.types import _convert_set_defaults
|
|
@@ -92,13 +91,6 @@ class ResourceTemplate(BaseModel):
|
|
| 92 |
except Exception as e:
|
| 93 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 94 |
|
| 95 |
-
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 96 |
-
"""Copy the resource template with optional updates."""
|
| 97 |
-
data = self.model_dump()
|
| 98 |
-
if updates:
|
| 99 |
-
data.update(updates)
|
| 100 |
-
return type(self)(**data)
|
| 101 |
-
|
| 102 |
def __eq__(self, other: object) -> bool:
|
| 103 |
if not isinstance(other, ResourceTemplate):
|
| 104 |
return False
|
|
|
|
| 8 |
from typing import Annotated, Any
|
| 9 |
|
| 10 |
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
|
|
|
|
| 11 |
|
| 12 |
from fastmcp.resources.types import FunctionResource, Resource
|
| 13 |
from fastmcp.utilities.types import _convert_set_defaults
|
|
|
|
| 91 |
except Exception as e:
|
| 92 |
raise ValueError(f"Error creating resource from template: {e}")
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
def __eq__(self, other: object) -> bool:
|
| 95 |
if not isinstance(other, ResourceTemplate):
|
| 96 |
return False
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -5,7 +5,6 @@ from collections.abc import Callable
|
|
| 5 |
from typing import TYPE_CHECKING, Annotated, Any
|
| 6 |
|
| 7 |
from pydantic import BaseModel, BeforeValidator, Field
|
| 8 |
-
from typing_extensions import Self
|
| 9 |
|
| 10 |
from fastmcp.exceptions import ToolError
|
| 11 |
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
|
@@ -102,13 +101,6 @@ class Tool(BaseModel):
|
|
| 102 |
except Exception as e:
|
| 103 |
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
| 104 |
|
| 105 |
-
def copy(self, updates: dict[str, Any] | None = None) -> Self:
|
| 106 |
-
"""Copy the tool with optional updates."""
|
| 107 |
-
data = self.model_dump()
|
| 108 |
-
if updates:
|
| 109 |
-
data.update(updates)
|
| 110 |
-
return type(self)(**data)
|
| 111 |
-
|
| 112 |
def __eq__(self, other: object) -> bool:
|
| 113 |
if not isinstance(other, Tool):
|
| 114 |
return False
|
|
|
|
| 5 |
from typing import TYPE_CHECKING, Annotated, Any
|
| 6 |
|
| 7 |
from pydantic import BaseModel, BeforeValidator, Field
|
|
|
|
| 8 |
|
| 9 |
from fastmcp.exceptions import ToolError
|
| 10 |
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
|
|
|
| 101 |
except Exception as e:
|
| 102 |
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def __eq__(self, other: object) -> bool:
|
| 105 |
if not isinstance(other, Tool):
|
| 106 |
return False
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations as _annotations
|
| 2 |
|
|
|
|
| 3 |
from collections.abc import Callable
|
| 4 |
from typing import TYPE_CHECKING, Any
|
| 5 |
|
|
@@ -90,7 +91,9 @@ class ToolManager:
|
|
| 90 |
for name, tool in tool_manager._tools.items():
|
| 91 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 92 |
|
| 93 |
-
new_tool =
|
|
|
|
|
|
|
| 94 |
# Store the copied tool
|
| 95 |
self.add_tool(new_tool)
|
| 96 |
logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')
|
|
|
|
| 1 |
from __future__ import annotations as _annotations
|
| 2 |
|
| 3 |
+
import copy
|
| 4 |
from collections.abc import Callable
|
| 5 |
from typing import TYPE_CHECKING, Any
|
| 6 |
|
|
|
|
| 91 |
for name, tool in tool_manager._tools.items():
|
| 92 |
prefixed_name = f"{prefix}{name}" if prefix else name
|
| 93 |
|
| 94 |
+
new_tool = copy.copy(tool)
|
| 95 |
+
new_tool.name = prefixed_name
|
| 96 |
+
|
| 97 |
# Store the copied tool
|
| 98 |
self.add_tool(new_tool)
|
| 99 |
logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -150,93 +150,6 @@ def _resolve_ref(
|
|
| 150 |
return item
|
| 151 |
|
| 152 |
|
| 153 |
-
def _extract_schema_as_dict(
|
| 154 |
-
schema_obj: Schema | Reference, openapi: OpenAPI
|
| 155 |
-
) -> JsonSchema:
|
| 156 |
-
"""Resolves a schema/reference and returns it as a dictionary."""
|
| 157 |
-
resolved_schema = _resolve_ref(schema_obj, openapi)
|
| 158 |
-
if isinstance(resolved_schema, Schema):
|
| 159 |
-
# Using exclude_none=True might be better than exclude_unset sometimes
|
| 160 |
-
return resolved_schema.model_dump(mode="json", by_alias=True, exclude_none=True)
|
| 161 |
-
elif isinstance(resolved_schema, dict):
|
| 162 |
-
logger.warning(
|
| 163 |
-
"Resolved schema reference resulted in a dict, not a Schema model."
|
| 164 |
-
)
|
| 165 |
-
return resolved_schema
|
| 166 |
-
else:
|
| 167 |
-
ref_str = getattr(schema_obj, "ref", "unknown")
|
| 168 |
-
logger.warning(
|
| 169 |
-
f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict."
|
| 170 |
-
)
|
| 171 |
-
return {}
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def _convert_to_parameter_location(param_in: str) -> ParameterLocation:
|
| 175 |
-
"""Convert string parameter location to our ParameterLocation type."""
|
| 176 |
-
if param_in == "path":
|
| 177 |
-
return "path"
|
| 178 |
-
elif param_in == "query":
|
| 179 |
-
return "query"
|
| 180 |
-
elif param_in == "header":
|
| 181 |
-
return "header"
|
| 182 |
-
elif param_in == "cookie":
|
| 183 |
-
return "cookie"
|
| 184 |
-
else:
|
| 185 |
-
logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
|
| 186 |
-
return "query"
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def _extract_responses(
|
| 190 |
-
operation_responses: dict[str, Response | Reference] | None,
|
| 191 |
-
openapi: OpenAPI,
|
| 192 |
-
) -> dict[str, ResponseInfo]:
|
| 193 |
-
"""Extracts and resolves response information for an operation."""
|
| 194 |
-
extracted_responses: dict[str, ResponseInfo] = {}
|
| 195 |
-
if not operation_responses:
|
| 196 |
-
return extracted_responses
|
| 197 |
-
|
| 198 |
-
for status_code, resp_or_ref in operation_responses.items():
|
| 199 |
-
try:
|
| 200 |
-
response = cast(Response, _resolve_ref(resp_or_ref, openapi))
|
| 201 |
-
if not isinstance(response, Response):
|
| 202 |
-
ref_str = getattr(resp_or_ref, "ref", "unknown")
|
| 203 |
-
logger.warning(
|
| 204 |
-
f"Expected Response after resolving ref '{ref_str}' for status code {status_code}, got {type(response)}. Skipping."
|
| 205 |
-
)
|
| 206 |
-
continue
|
| 207 |
-
|
| 208 |
-
content_schemas: dict[str, JsonSchema] = {}
|
| 209 |
-
if response.content:
|
| 210 |
-
for media_type_str, media_type_obj in response.content.items():
|
| 211 |
-
if (
|
| 212 |
-
isinstance(media_type_obj, MediaType)
|
| 213 |
-
and media_type_obj.media_type_schema
|
| 214 |
-
):
|
| 215 |
-
try:
|
| 216 |
-
schema_dict = _extract_schema_as_dict(
|
| 217 |
-
media_type_obj.media_type_schema, openapi
|
| 218 |
-
)
|
| 219 |
-
content_schemas[media_type_str] = schema_dict
|
| 220 |
-
except ValueError as schema_err:
|
| 221 |
-
logger.error(
|
| 222 |
-
f"Failed to extract schema for media type '{media_type_str}' in response {status_code}: {schema_err}"
|
| 223 |
-
)
|
| 224 |
-
|
| 225 |
-
resp_info = ResponseInfo(
|
| 226 |
-
description=response.description, content_schema=content_schemas
|
| 227 |
-
)
|
| 228 |
-
extracted_responses[str(status_code)] = resp_info
|
| 229 |
-
|
| 230 |
-
except (ValidationError, ValueError, AttributeError) as e:
|
| 231 |
-
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
| 232 |
-
logger.error(
|
| 233 |
-
f"Failed to extract response for status code {status_code} (ref: '{ref_name}'): {e}",
|
| 234 |
-
exc_info=False,
|
| 235 |
-
)
|
| 236 |
-
|
| 237 |
-
return extracted_responses
|
| 238 |
-
|
| 239 |
-
|
| 240 |
# --- Main Parsing Function ---
|
| 241 |
# (No changes needed in the main loop logic, only in the helpers it calls)
|
| 242 |
def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
|
|
|
|
| 150 |
return item
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
# --- Main Parsing Function ---
|
| 154 |
# (No changes needed in the main loop logic, only in the helpers it calls)
|
| 155 |
def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
|
tests/server/test_openapi.py
CHANGED
|
@@ -73,7 +73,7 @@ def api_client(fastapi_app: FastAPI) -> AsyncClient:
|
|
| 73 |
|
| 74 |
|
| 75 |
@pytest.fixture
|
| 76 |
-
async def
|
| 77 |
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 78 |
) -> FastMCPOpenAPI:
|
| 79 |
openapi_spec = fastapi_app.openapi()
|
|
@@ -113,11 +113,11 @@ async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
|
|
| 113 |
|
| 114 |
|
| 115 |
class TestTools:
|
| 116 |
-
async def test_list_tools(self,
|
| 117 |
"""
|
| 118 |
By default, tools exclude GET methods
|
| 119 |
"""
|
| 120 |
-
tools = await
|
| 121 |
assert len(tools) == 2
|
| 122 |
|
| 123 |
assert tools[0].model_dump() == dict(
|
|
@@ -148,12 +148,12 @@ class TestTools:
|
|
| 148 |
)
|
| 149 |
|
| 150 |
async def test_call_create_user_tool(
|
| 151 |
-
self,
|
| 152 |
):
|
| 153 |
"""
|
| 154 |
The tool created by the OpenAPI server should be the same as the original
|
| 155 |
"""
|
| 156 |
-
tool_response = await
|
| 157 |
"create_user_users_post", {"name": "David", "active": False}
|
| 158 |
)
|
| 159 |
assert tool_response == User(id=4, name="David", active=False)
|
|
@@ -164,19 +164,19 @@ class TestTools:
|
|
| 164 |
assert len(response.json()) == 4
|
| 165 |
|
| 166 |
# Check that the user was created via MCP
|
| 167 |
-
user_response = await
|
| 168 |
"resource://openapi/get_user_users__user_id__get/4"
|
| 169 |
)
|
| 170 |
user = user_response[0].content
|
| 171 |
assert user == tool_response.model_dump()
|
| 172 |
|
| 173 |
async def test_call_update_user_name_tool(
|
| 174 |
-
self,
|
| 175 |
):
|
| 176 |
"""
|
| 177 |
The tool created by the OpenAPI server should be the same as the original
|
| 178 |
"""
|
| 179 |
-
tool_response = await
|
| 180 |
"update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"}
|
| 181 |
)
|
| 182 |
assert tool_response == dict(id=1, name="XYZ", active=True)
|
|
@@ -186,7 +186,7 @@ class TestTools:
|
|
| 186 |
assert dict(id=1, name="XYZ", active=True) in response.json()
|
| 187 |
|
| 188 |
# Check that the user was updated via MCP
|
| 189 |
-
user_response = await
|
| 190 |
"resource://openapi/get_user_users__user_id__get/1"
|
| 191 |
)
|
| 192 |
user = user_response[0].content
|
|
@@ -194,17 +194,20 @@ class TestTools:
|
|
| 194 |
|
| 195 |
|
| 196 |
class TestResources:
|
| 197 |
-
async def test_list_resources(self,
|
| 198 |
"""
|
| 199 |
By default, resources exclude GET methods without parameters
|
| 200 |
"""
|
| 201 |
-
resources = await
|
| 202 |
assert len(resources) == 1
|
| 203 |
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
|
| 204 |
assert resources[0].name == "get_users_users_get"
|
| 205 |
|
| 206 |
async def test_get_resource(
|
| 207 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 208 |
):
|
| 209 |
"""
|
| 210 |
The resource created by the OpenAPI server should be the same as the original
|
|
@@ -212,7 +215,7 @@ class TestResources:
|
|
| 212 |
json_users = TypeAdapter(list[User]).dump_python(
|
| 213 |
sorted(users_db.values(), key=lambda x: x.id)
|
| 214 |
)
|
| 215 |
-
resource_response = await
|
| 216 |
"resource://openapi/get_users_users_get"
|
| 217 |
)
|
| 218 |
resource = resource_response[0].content
|
|
@@ -222,11 +225,13 @@ class TestResources:
|
|
| 222 |
|
| 223 |
|
| 224 |
class TestResourceTemplates:
|
| 225 |
-
async def test_list_resource_templates(
|
|
|
|
|
|
|
| 226 |
"""
|
| 227 |
By default, resource templates exclude GET methods without parameters
|
| 228 |
"""
|
| 229 |
-
resource_templates = await
|
| 230 |
assert len(resource_templates) == 1
|
| 231 |
assert resource_templates[0].name == "get_user_users__user_id__get"
|
| 232 |
assert (
|
|
@@ -235,13 +240,16 @@ class TestResourceTemplates:
|
|
| 235 |
)
|
| 236 |
|
| 237 |
async def test_get_resource_template(
|
| 238 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 239 |
):
|
| 240 |
"""
|
| 241 |
The resource template created by the OpenAPI server should be the same as the original
|
| 242 |
"""
|
| 243 |
user_id = 2
|
| 244 |
-
resource_response = await
|
| 245 |
f"resource://openapi/get_user_users__user_id__get/{user_id}"
|
| 246 |
)
|
| 247 |
|
|
@@ -252,21 +260,23 @@ class TestResourceTemplates:
|
|
| 252 |
|
| 253 |
|
| 254 |
class TestPrompts:
|
| 255 |
-
async def test_list_prompts(self,
|
| 256 |
"""
|
| 257 |
By default, there are no prompts.
|
| 258 |
"""
|
| 259 |
-
prompts = await
|
| 260 |
assert len(prompts) == 0
|
| 261 |
|
| 262 |
|
| 263 |
class TestTagTransfer:
|
| 264 |
-
"""Tests for transferring tags from OpenAPI to MCP objects."""
|
| 265 |
|
| 266 |
-
async def test_tags_transferred_to_tools(
|
|
|
|
|
|
|
| 267 |
"""Test that tags from OpenAPI routes are correctly transferred to Tools."""
|
| 268 |
# Get internal tools directly (not the public API which returns MCP.Content)
|
| 269 |
-
tools =
|
| 270 |
|
| 271 |
# Find the create_user and update_user_name tools
|
| 272 |
create_user_tool = next(
|
|
@@ -293,10 +303,12 @@ class TestTagTransfer:
|
|
| 293 |
assert "update" in update_user_tool.tags
|
| 294 |
assert len(update_user_tool.tags) == 2
|
| 295 |
|
| 296 |
-
async def test_tags_transferred_to_resources(
|
|
|
|
|
|
|
| 297 |
"""Test that tags from OpenAPI routes are correctly transferred to Resources."""
|
| 298 |
# Get internal resources directly
|
| 299 |
-
resources =
|
| 300 |
|
| 301 |
# Find the get_users resource
|
| 302 |
get_users_resource = next(
|
|
@@ -311,11 +323,11 @@ class TestTagTransfer:
|
|
| 311 |
assert len(get_users_resource.tags) == 2
|
| 312 |
|
| 313 |
async def test_tags_transferred_to_resource_templates(
|
| 314 |
-
self,
|
| 315 |
):
|
| 316 |
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
|
| 317 |
# Get internal resource templates directly
|
| 318 |
-
templates =
|
| 319 |
|
| 320 |
# Find the get_user template
|
| 321 |
get_user_template = next(
|
|
@@ -330,11 +342,11 @@ class TestTagTransfer:
|
|
| 330 |
assert len(get_user_template.tags) == 2
|
| 331 |
|
| 332 |
async def test_tags_preserved_in_resources_created_from_templates(
|
| 333 |
-
self,
|
| 334 |
):
|
| 335 |
"""Test that tags are preserved when creating resources from templates."""
|
| 336 |
# Get internal resource templates directly
|
| 337 |
-
templates =
|
| 338 |
|
| 339 |
# Find the get_user template
|
| 340 |
get_user_template = next(
|
|
@@ -353,3 +365,355 @@ class TestTagTransfer:
|
|
| 353 |
assert "users" in resource.tags
|
| 354 |
assert "detail" in resource.tags
|
| 355 |
assert len(resource.tags) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
@pytest.fixture
|
| 76 |
+
async def fastmcp_openapi_server(
|
| 77 |
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 78 |
) -> FastMCPOpenAPI:
|
| 79 |
openapi_spec = fastapi_app.openapi()
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
class TestTools:
|
| 116 |
+
async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
| 117 |
"""
|
| 118 |
By default, tools exclude GET methods
|
| 119 |
"""
|
| 120 |
+
tools = await fastmcp_openapi_server._mcp_list_tools()
|
| 121 |
assert len(tools) == 2
|
| 122 |
|
| 123 |
assert tools[0].model_dump() == dict(
|
|
|
|
| 148 |
)
|
| 149 |
|
| 150 |
async def test_call_create_user_tool(
|
| 151 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI, api_client
|
| 152 |
):
|
| 153 |
"""
|
| 154 |
The tool created by the OpenAPI server should be the same as the original
|
| 155 |
"""
|
| 156 |
+
tool_response = await fastmcp_openapi_server.call_tool(
|
| 157 |
"create_user_users_post", {"name": "David", "active": False}
|
| 158 |
)
|
| 159 |
assert tool_response == User(id=4, name="David", active=False)
|
|
|
|
| 164 |
assert len(response.json()) == 4
|
| 165 |
|
| 166 |
# Check that the user was created via MCP
|
| 167 |
+
user_response = await fastmcp_openapi_server._mcp_read_resource(
|
| 168 |
"resource://openapi/get_user_users__user_id__get/4"
|
| 169 |
)
|
| 170 |
user = user_response[0].content
|
| 171 |
assert user == tool_response.model_dump()
|
| 172 |
|
| 173 |
async def test_call_update_user_name_tool(
|
| 174 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI, api_client
|
| 175 |
):
|
| 176 |
"""
|
| 177 |
The tool created by the OpenAPI server should be the same as the original
|
| 178 |
"""
|
| 179 |
+
tool_response = await fastmcp_openapi_server.call_tool(
|
| 180 |
"update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"}
|
| 181 |
)
|
| 182 |
assert tool_response == dict(id=1, name="XYZ", active=True)
|
|
|
|
| 186 |
assert dict(id=1, name="XYZ", active=True) in response.json()
|
| 187 |
|
| 188 |
# Check that the user was updated via MCP
|
| 189 |
+
user_response = await fastmcp_openapi_server._mcp_read_resource(
|
| 190 |
"resource://openapi/get_user_users__user_id__get/1"
|
| 191 |
)
|
| 192 |
user = user_response[0].content
|
|
|
|
| 194 |
|
| 195 |
|
| 196 |
class TestResources:
|
| 197 |
+
async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
| 198 |
"""
|
| 199 |
By default, resources exclude GET methods without parameters
|
| 200 |
"""
|
| 201 |
+
resources = await fastmcp_openapi_server._mcp_list_resources()
|
| 202 |
assert len(resources) == 1
|
| 203 |
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
|
| 204 |
assert resources[0].name == "get_users_users_get"
|
| 205 |
|
| 206 |
async def test_get_resource(
|
| 207 |
+
self,
|
| 208 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 209 |
+
api_client,
|
| 210 |
+
users_db: dict[int, User],
|
| 211 |
):
|
| 212 |
"""
|
| 213 |
The resource created by the OpenAPI server should be the same as the original
|
|
|
|
| 215 |
json_users = TypeAdapter(list[User]).dump_python(
|
| 216 |
sorted(users_db.values(), key=lambda x: x.id)
|
| 217 |
)
|
| 218 |
+
resource_response = await fastmcp_openapi_server._mcp_read_resource(
|
| 219 |
"resource://openapi/get_users_users_get"
|
| 220 |
)
|
| 221 |
resource = resource_response[0].content
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
class TestResourceTemplates:
|
| 228 |
+
async def test_list_resource_templates(
|
| 229 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 230 |
+
):
|
| 231 |
"""
|
| 232 |
By default, resource templates exclude GET methods without parameters
|
| 233 |
"""
|
| 234 |
+
resource_templates = await fastmcp_openapi_server._mcp_list_resource_templates()
|
| 235 |
assert len(resource_templates) == 1
|
| 236 |
assert resource_templates[0].name == "get_user_users__user_id__get"
|
| 237 |
assert (
|
|
|
|
| 240 |
)
|
| 241 |
|
| 242 |
async def test_get_resource_template(
|
| 243 |
+
self,
|
| 244 |
+
fastmcp_openapi_server: FastMCPOpenAPI,
|
| 245 |
+
api_client,
|
| 246 |
+
users_db: dict[int, User],
|
| 247 |
):
|
| 248 |
"""
|
| 249 |
The resource template created by the OpenAPI server should be the same as the original
|
| 250 |
"""
|
| 251 |
user_id = 2
|
| 252 |
+
resource_response = await fastmcp_openapi_server._mcp_read_resource(
|
| 253 |
f"resource://openapi/get_user_users__user_id__get/{user_id}"
|
| 254 |
)
|
| 255 |
|
|
|
|
| 260 |
|
| 261 |
|
| 262 |
class TestPrompts:
|
| 263 |
+
async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
| 264 |
"""
|
| 265 |
By default, there are no prompts.
|
| 266 |
"""
|
| 267 |
+
prompts = await fastmcp_openapi_server._mcp_list_prompts()
|
| 268 |
assert len(prompts) == 0
|
| 269 |
|
| 270 |
|
| 271 |
class TestTagTransfer:
|
| 272 |
+
"""Tests for transferring tags from OpenAPI routes to MCP objects."""
|
| 273 |
|
| 274 |
+
async def test_tags_transferred_to_tools(
|
| 275 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 276 |
+
):
|
| 277 |
"""Test that tags from OpenAPI routes are correctly transferred to Tools."""
|
| 278 |
# Get internal tools directly (not the public API which returns MCP.Content)
|
| 279 |
+
tools = fastmcp_openapi_server._tool_manager.list_tools()
|
| 280 |
|
| 281 |
# Find the create_user and update_user_name tools
|
| 282 |
create_user_tool = next(
|
|
|
|
| 303 |
assert "update" in update_user_tool.tags
|
| 304 |
assert len(update_user_tool.tags) == 2
|
| 305 |
|
| 306 |
+
async def test_tags_transferred_to_resources(
|
| 307 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 308 |
+
):
|
| 309 |
"""Test that tags from OpenAPI routes are correctly transferred to Resources."""
|
| 310 |
# Get internal resources directly
|
| 311 |
+
resources = fastmcp_openapi_server._resource_manager.list_resources()
|
| 312 |
|
| 313 |
# Find the get_users resource
|
| 314 |
get_users_resource = next(
|
|
|
|
| 323 |
assert len(get_users_resource.tags) == 2
|
| 324 |
|
| 325 |
async def test_tags_transferred_to_resource_templates(
|
| 326 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 327 |
):
|
| 328 |
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
|
| 329 |
# Get internal resource templates directly
|
| 330 |
+
templates = fastmcp_openapi_server._resource_manager.list_templates()
|
| 331 |
|
| 332 |
# Find the get_user template
|
| 333 |
get_user_template = next(
|
|
|
|
| 342 |
assert len(get_user_template.tags) == 2
|
| 343 |
|
| 344 |
async def test_tags_preserved_in_resources_created_from_templates(
|
| 345 |
+
self, fastmcp_openapi_server: FastMCPOpenAPI
|
| 346 |
):
|
| 347 |
"""Test that tags are preserved when creating resources from templates."""
|
| 348 |
# Get internal resource templates directly
|
| 349 |
+
templates = fastmcp_openapi_server._resource_manager.list_templates()
|
| 350 |
|
| 351 |
# Find the get_user template
|
| 352 |
get_user_template = next(
|
|
|
|
| 365 |
assert "users" in resource.tags
|
| 366 |
assert "detail" in resource.tags
|
| 367 |
assert len(resource.tags) == 2
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
class TestOpenAPI30Compatibility:
|
| 371 |
+
"""Tests for compatibility with OpenAPI 3.0 specifications."""
|
| 372 |
+
|
| 373 |
+
@pytest.fixture
|
| 374 |
+
def openapi_30_spec(self) -> dict:
|
| 375 |
+
"""Fixture that returns a simple OpenAPI 3.0 specification."""
|
| 376 |
+
return {
|
| 377 |
+
"openapi": "3.0.0",
|
| 378 |
+
"info": {"title": "Product API (3.0)", "version": "1.0.0"},
|
| 379 |
+
"paths": {
|
| 380 |
+
"/products": {
|
| 381 |
+
"get": {
|
| 382 |
+
"operationId": "listProducts",
|
| 383 |
+
"summary": "List all products",
|
| 384 |
+
"responses": {"200": {"description": "A list of products"}},
|
| 385 |
+
},
|
| 386 |
+
"post": {
|
| 387 |
+
"operationId": "createProduct",
|
| 388 |
+
"summary": "Create a new product",
|
| 389 |
+
"requestBody": {
|
| 390 |
+
"required": True,
|
| 391 |
+
"content": {
|
| 392 |
+
"application/json": {
|
| 393 |
+
"schema": {
|
| 394 |
+
"type": "object",
|
| 395 |
+
"properties": {
|
| 396 |
+
"name": {"type": "string"},
|
| 397 |
+
"price": {"type": "number"},
|
| 398 |
+
},
|
| 399 |
+
"required": ["name", "price"],
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
},
|
| 403 |
+
},
|
| 404 |
+
"responses": {"201": {"description": "Product created"}},
|
| 405 |
+
},
|
| 406 |
+
},
|
| 407 |
+
"/products/{product_id}": {
|
| 408 |
+
"get": {
|
| 409 |
+
"operationId": "getProduct",
|
| 410 |
+
"summary": "Get product by ID",
|
| 411 |
+
"parameters": [
|
| 412 |
+
{
|
| 413 |
+
"name": "product_id",
|
| 414 |
+
"in": "path",
|
| 415 |
+
"required": True,
|
| 416 |
+
"schema": {"type": "string"},
|
| 417 |
+
}
|
| 418 |
+
],
|
| 419 |
+
"responses": {"200": {"description": "A product"}},
|
| 420 |
+
}
|
| 421 |
+
},
|
| 422 |
+
},
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
@pytest.fixture
|
| 426 |
+
async def mock_30_client(self) -> httpx.AsyncClient:
|
| 427 |
+
"""Mock client that returns predefined responses for the 3.0 API."""
|
| 428 |
+
|
| 429 |
+
async def _responder(request):
|
| 430 |
+
if request.url.path == "/products" and request.method == "GET":
|
| 431 |
+
return httpx.Response(
|
| 432 |
+
200,
|
| 433 |
+
json=[
|
| 434 |
+
{"id": "p1", "name": "Product 1", "price": 19.99},
|
| 435 |
+
{"id": "p2", "name": "Product 2", "price": 29.99},
|
| 436 |
+
],
|
| 437 |
+
)
|
| 438 |
+
elif request.url.path == "/products" and request.method == "POST":
|
| 439 |
+
import json
|
| 440 |
+
|
| 441 |
+
data = json.loads(request.content)
|
| 442 |
+
return httpx.Response(
|
| 443 |
+
201, json={"id": "p3", "name": data["name"], "price": data["price"]}
|
| 444 |
+
)
|
| 445 |
+
elif request.url.path.startswith("/products/") and request.method == "GET":
|
| 446 |
+
product_id = request.url.path.split("/")[-1]
|
| 447 |
+
products = {
|
| 448 |
+
"p1": {"id": "p1", "name": "Product 1", "price": 19.99},
|
| 449 |
+
"p2": {"id": "p2", "name": "Product 2", "price": 29.99},
|
| 450 |
+
}
|
| 451 |
+
if product_id in products:
|
| 452 |
+
return httpx.Response(200, json=products[product_id])
|
| 453 |
+
return httpx.Response(404, json={"error": "Product not found"})
|
| 454 |
+
return httpx.Response(404)
|
| 455 |
+
|
| 456 |
+
transport = httpx.MockTransport(_responder)
|
| 457 |
+
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 458 |
+
|
| 459 |
+
@pytest.fixture
|
| 460 |
+
async def openapi_30_server(
|
| 461 |
+
self, openapi_30_spec, mock_30_client
|
| 462 |
+
) -> FastMCPOpenAPI:
|
| 463 |
+
"""Create a FastMCPOpenAPI server from the OpenAPI 3.0 spec."""
|
| 464 |
+
return FastMCPOpenAPI(
|
| 465 |
+
openapi_spec=openapi_30_spec, client=mock_30_client, name="Product API 3.0"
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
async def test_server_creation(self, openapi_30_server):
|
| 469 |
+
"""Test that a server can be created from an OpenAPI 3.0 spec."""
|
| 470 |
+
assert isinstance(openapi_30_server, FastMCP)
|
| 471 |
+
assert openapi_30_server.name == "Product API 3.0"
|
| 472 |
+
|
| 473 |
+
async def test_resource_discovery(self, openapi_30_server):
|
| 474 |
+
"""Test that resources are correctly discovered from an OpenAPI 3.0 spec."""
|
| 475 |
+
resources = await openapi_30_server._mcp_list_resources()
|
| 476 |
+
assert len(resources) == 1
|
| 477 |
+
assert resources[0].uri == AnyUrl("resource://openapi/listProducts")
|
| 478 |
+
|
| 479 |
+
async def test_resource_template_discovery(self, openapi_30_server):
|
| 480 |
+
"""Test that resource templates are correctly discovered from an OpenAPI 3.0 spec."""
|
| 481 |
+
templates = await openapi_30_server._mcp_list_resource_templates()
|
| 482 |
+
assert len(templates) == 1
|
| 483 |
+
assert templates[0].name == "getProduct"
|
| 484 |
+
assert templates[0].uriTemplate == r"resource://openapi/getProduct/{product_id}"
|
| 485 |
+
|
| 486 |
+
async def test_tool_discovery(self, openapi_30_server):
|
| 487 |
+
"""Test that tools are correctly discovered from an OpenAPI 3.0 spec."""
|
| 488 |
+
tools = await openapi_30_server._mcp_list_tools()
|
| 489 |
+
assert len(tools) == 1
|
| 490 |
+
assert tools[0].name == "createProduct"
|
| 491 |
+
assert "name" in tools[0].inputSchema["properties"]
|
| 492 |
+
assert "price" in tools[0].inputSchema["properties"]
|
| 493 |
+
|
| 494 |
+
async def test_resource_access(self, openapi_30_server):
|
| 495 |
+
"""Test reading a resource from an OpenAPI 3.0 server."""
|
| 496 |
+
resource_response = await openapi_30_server._mcp_read_resource(
|
| 497 |
+
"resource://openapi/listProducts"
|
| 498 |
+
)
|
| 499 |
+
content = resource_response[0].content
|
| 500 |
+
assert len(content) == 2
|
| 501 |
+
assert content[0]["name"] == "Product 1"
|
| 502 |
+
assert content[1]["name"] == "Product 2"
|
| 503 |
+
|
| 504 |
+
async def test_resource_template_access(self, openapi_30_server):
|
| 505 |
+
"""Test reading a resource from template from an OpenAPI 3.0 server."""
|
| 506 |
+
resource_response = await openapi_30_server._mcp_read_resource(
|
| 507 |
+
"resource://openapi/getProduct/p1"
|
| 508 |
+
)
|
| 509 |
+
content = resource_response[0].content
|
| 510 |
+
assert content["id"] == "p1"
|
| 511 |
+
assert content["name"] == "Product 1"
|
| 512 |
+
assert content["price"] == 19.99
|
| 513 |
+
|
| 514 |
+
async def test_tool_execution(self, openapi_30_server):
|
| 515 |
+
"""Test executing a tool from an OpenAPI 3.0 server."""
|
| 516 |
+
tool_response = await openapi_30_server.call_tool(
|
| 517 |
+
"createProduct", {"name": "New Product", "price": 39.99}
|
| 518 |
+
)
|
| 519 |
+
assert tool_response["id"] == "p3"
|
| 520 |
+
assert tool_response["name"] == "New Product"
|
| 521 |
+
assert tool_response["price"] == 39.99
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
class TestOpenAPI31Compatibility:
|
| 525 |
+
"""Tests for compatibility with OpenAPI 3.1 specifications."""
|
| 526 |
+
|
| 527 |
+
@pytest.fixture
|
| 528 |
+
def openapi_31_spec(self) -> dict:
|
| 529 |
+
"""Fixture that returns a simple OpenAPI 3.1 specification."""
|
| 530 |
+
return {
|
| 531 |
+
"openapi": "3.1.0",
|
| 532 |
+
"info": {"title": "Order API (3.1)", "version": "1.0.0"},
|
| 533 |
+
"paths": {
|
| 534 |
+
"/orders": {
|
| 535 |
+
"get": {
|
| 536 |
+
"operationId": "listOrders",
|
| 537 |
+
"summary": "List all orders",
|
| 538 |
+
"responses": {"200": {"description": "A list of orders"}},
|
| 539 |
+
},
|
| 540 |
+
"post": {
|
| 541 |
+
"operationId": "createOrder",
|
| 542 |
+
"summary": "Place a new order",
|
| 543 |
+
"requestBody": {
|
| 544 |
+
"required": True,
|
| 545 |
+
"content": {
|
| 546 |
+
"application/json": {
|
| 547 |
+
"schema": {
|
| 548 |
+
"type": "object",
|
| 549 |
+
"properties": {
|
| 550 |
+
"customer": {"type": "string"},
|
| 551 |
+
"items": {
|
| 552 |
+
"type": "array",
|
| 553 |
+
"items": {"type": "string"},
|
| 554 |
+
},
|
| 555 |
+
},
|
| 556 |
+
"required": ["customer", "items"],
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
},
|
| 560 |
+
},
|
| 561 |
+
"responses": {"201": {"description": "Order created"}},
|
| 562 |
+
},
|
| 563 |
+
},
|
| 564 |
+
"/orders/{order_id}": {
|
| 565 |
+
"get": {
|
| 566 |
+
"operationId": "getOrder",
|
| 567 |
+
"summary": "Get order by ID",
|
| 568 |
+
"parameters": [
|
| 569 |
+
{
|
| 570 |
+
"name": "order_id",
|
| 571 |
+
"in": "path",
|
| 572 |
+
"required": True,
|
| 573 |
+
"schema": {"type": "string"},
|
| 574 |
+
}
|
| 575 |
+
],
|
| 576 |
+
"responses": {"200": {"description": "An order"}},
|
| 577 |
+
}
|
| 578 |
+
},
|
| 579 |
+
},
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
@pytest.fixture
|
| 583 |
+
async def mock_31_client(self) -> httpx.AsyncClient:
|
| 584 |
+
"""Mock client that returns predefined responses for the 3.1 API."""
|
| 585 |
+
|
| 586 |
+
async def _responder(request):
|
| 587 |
+
if request.url.path == "/orders" and request.method == "GET":
|
| 588 |
+
return httpx.Response(
|
| 589 |
+
200,
|
| 590 |
+
json=[
|
| 591 |
+
{"id": "o1", "customer": "Alice", "items": ["item1", "item2"]},
|
| 592 |
+
{"id": "o2", "customer": "Bob", "items": ["item3"]},
|
| 593 |
+
],
|
| 594 |
+
)
|
| 595 |
+
elif request.url.path == "/orders" and request.method == "POST":
|
| 596 |
+
import json
|
| 597 |
+
|
| 598 |
+
data = json.loads(request.content)
|
| 599 |
+
return httpx.Response(
|
| 600 |
+
201,
|
| 601 |
+
json={
|
| 602 |
+
"id": "o3",
|
| 603 |
+
"customer": data["customer"],
|
| 604 |
+
"items": data["items"],
|
| 605 |
+
},
|
| 606 |
+
)
|
| 607 |
+
elif request.url.path.startswith("/orders/") and request.method == "GET":
|
| 608 |
+
order_id = request.url.path.split("/")[-1]
|
| 609 |
+
orders = {
|
| 610 |
+
"o1": {
|
| 611 |
+
"id": "o1",
|
| 612 |
+
"customer": "Alice",
|
| 613 |
+
"items": ["item1", "item2"],
|
| 614 |
+
},
|
| 615 |
+
"o2": {"id": "o2", "customer": "Bob", "items": ["item3"]},
|
| 616 |
+
}
|
| 617 |
+
if order_id in orders:
|
| 618 |
+
return httpx.Response(200, json=orders[order_id])
|
| 619 |
+
return httpx.Response(404, json={"error": "Order not found"})
|
| 620 |
+
return httpx.Response(404)
|
| 621 |
+
|
| 622 |
+
transport = httpx.MockTransport(_responder)
|
| 623 |
+
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 624 |
+
|
| 625 |
+
@pytest.fixture
|
| 626 |
+
async def openapi_31_server(
|
| 627 |
+
self, openapi_31_spec, mock_31_client
|
| 628 |
+
) -> FastMCPOpenAPI:
|
| 629 |
+
"""Create a FastMCPOpenAPI server from the OpenAPI 3.1 spec."""
|
| 630 |
+
return FastMCPOpenAPI(
|
| 631 |
+
openapi_spec=openapi_31_spec, client=mock_31_client, name="Order API 3.1"
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
async def test_server_creation(self, openapi_31_server):
|
| 635 |
+
"""Test that a server can be created from an OpenAPI 3.1 spec."""
|
| 636 |
+
assert isinstance(openapi_31_server, FastMCP)
|
| 637 |
+
assert openapi_31_server.name == "Order API 3.1"
|
| 638 |
+
|
| 639 |
+
async def test_resource_discovery(self, openapi_31_server):
|
| 640 |
+
"""Test that resources are correctly discovered from an OpenAPI 3.1 spec."""
|
| 641 |
+
resources = await openapi_31_server._mcp_list_resources()
|
| 642 |
+
assert len(resources) == 1
|
| 643 |
+
assert resources[0].uri == AnyUrl("resource://openapi/listOrders")
|
| 644 |
+
|
| 645 |
+
async def test_resource_template_discovery(self, openapi_31_server):
|
| 646 |
+
"""Test that resource templates are correctly discovered from an OpenAPI 3.1 spec."""
|
| 647 |
+
templates = await openapi_31_server._mcp_list_resource_templates()
|
| 648 |
+
assert len(templates) == 1
|
| 649 |
+
assert templates[0].name == "getOrder"
|
| 650 |
+
assert templates[0].uriTemplate == r"resource://openapi/getOrder/{order_id}"
|
| 651 |
+
|
| 652 |
+
async def test_tool_discovery(self, openapi_31_server):
|
| 653 |
+
"""Test that tools are correctly discovered from an OpenAPI 3.1 spec."""
|
| 654 |
+
tools = await openapi_31_server._mcp_list_tools()
|
| 655 |
+
assert len(tools) == 1
|
| 656 |
+
assert tools[0].name == "createOrder"
|
| 657 |
+
assert "customer" in tools[0].inputSchema["properties"]
|
| 658 |
+
assert "items" in tools[0].inputSchema["properties"]
|
| 659 |
+
|
| 660 |
+
async def test_resource_access(self, openapi_31_server):
|
| 661 |
+
"""Test reading a resource from an OpenAPI 3.1 server."""
|
| 662 |
+
resource_response = await openapi_31_server._mcp_read_resource(
|
| 663 |
+
"resource://openapi/listOrders"
|
| 664 |
+
)
|
| 665 |
+
content = resource_response[0].content
|
| 666 |
+
assert len(content) == 2
|
| 667 |
+
assert content[0]["customer"] == "Alice"
|
| 668 |
+
assert content[1]["customer"] == "Bob"
|
| 669 |
+
|
| 670 |
+
async def test_resource_template_access(self, openapi_31_server):
|
| 671 |
+
"""Test reading a resource from template from an OpenAPI 3.1 server."""
|
| 672 |
+
resource_response = await openapi_31_server._mcp_read_resource(
|
| 673 |
+
"resource://openapi/getOrder/o1"
|
| 674 |
+
)
|
| 675 |
+
content = resource_response[0].content
|
| 676 |
+
assert content["id"] == "o1"
|
| 677 |
+
assert content["customer"] == "Alice"
|
| 678 |
+
assert content["items"] == ["item1", "item2"]
|
| 679 |
+
|
| 680 |
+
async def test_tool_execution(self, openapi_31_server):
|
| 681 |
+
"""Test executing a tool from an OpenAPI 3.1 server."""
|
| 682 |
+
tool_response = await openapi_31_server.call_tool(
|
| 683 |
+
"createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
|
| 684 |
+
)
|
| 685 |
+
assert tool_response["id"] == "o3"
|
| 686 |
+
assert tool_response["customer"] == "Charlie"
|
| 687 |
+
assert tool_response["items"] == ["item4", "item5"]
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
class TestMountFastMCP:
|
| 691 |
+
"""Tests for mounting FastMCP servers."""
|
| 692 |
+
|
| 693 |
+
async def test_mount_fastmcp(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
| 694 |
+
"""Test mounting an OpenAPI server."""
|
| 695 |
+
mcp = FastMCP("MainApp")
|
| 696 |
+
|
| 697 |
+
mcp.mount("fastapi", fastmcp_openapi_server)
|
| 698 |
+
|
| 699 |
+
resources = await mcp._mcp_list_resources()
|
| 700 |
+
assert len(resources) == 1
|
| 701 |
+
assert resources[0].uri == AnyUrl(
|
| 702 |
+
"fastapi+resource://openapi/get_users_users_get"
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
templates = await mcp._mcp_list_resource_templates()
|
| 706 |
+
assert len(templates) == 1
|
| 707 |
+
assert templates[0].name == "get_user_users__user_id__get"
|
| 708 |
+
assert (
|
| 709 |
+
templates[0].uriTemplate
|
| 710 |
+
== r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}"
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
+
tools = await mcp._mcp_list_tools()
|
| 714 |
+
assert len(tools) == 2
|
| 715 |
+
assert tools[0].name == "fastapi_create_user_users_post"
|
| 716 |
+
assert tools[1].name == "fastapi_update_user_name_users__user_id__name_patch"
|
| 717 |
+
|
| 718 |
+
prompts = await mcp._mcp_list_prompts()
|
| 719 |
+
assert len(prompts) == 0
|
uv.lock
CHANGED
|
@@ -254,7 +254,7 @@ wheels = [
|
|
| 254 |
|
| 255 |
[[package]]
|
| 256 |
name = "fastmcp"
|
| 257 |
-
version = "2.1.
|
| 258 |
source = { editable = "." }
|
| 259 |
dependencies = [
|
| 260 |
{ name = "dotenv" },
|
|
@@ -1323,4 +1323,4 @@ dependencies = [
|
|
| 1323 |
sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 }
|
| 1324 |
wheels = [
|
| 1325 |
{ url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 },
|
| 1326 |
-
]
|
|
|
|
| 254 |
|
| 255 |
[[package]]
|
| 256 |
name = "fastmcp"
|
| 257 |
+
version = "2.1.2.dev2+c0de75e"
|
| 258 |
source = { editable = "." }
|
| 259 |
dependencies = [
|
| 260 |
{ name = "dotenv" },
|
|
|
|
| 1323 |
sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 }
|
| 1324 |
wheels = [
|
| 1325 |
{ url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 },
|
| 1326 |
+
]
|