Add fastapi conversion

This commit is contained in:
Jeremiah Lowin 2025-04-10 11:25:44 -04:00
commit 8720a78747
4 changed files with 99 additions and 62 deletions

View file

@ -13,18 +13,15 @@ from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.server import FastMCP
from fastmcp.tools.base import Tool
from fastmcp.utilities import openapi
from fastmcp.utilities.func_metadata import (
func_metadata as mcp_func_metadata,
)
from fastmcp.utilities.func_metadata import func_metadata
from fastmcp.utilities.logging import get_logger
# Re-export the formatter function for convenience
from fastmcp.utilities.openapi import format_description_with_responses
from fastmcp.utilities.openapi import (
_combine_schemas,
format_description_with_responses,
)
logger = get_logger(__name__)
# HTTP Methods as a Literal for type checking
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
@ -98,6 +95,13 @@ def _determine_route_type(
return RouteType.TOOL
# Placeholder function to provide function metadata
async def _openapi_passthrough(*args, **kwargs):
"""Placeholder function for OpenAPI endpoints."""
# This is kept for metadata generation purposes
pass
class OpenAPITool(Tool):
"""Tool implementation for OpenAPI endpoints."""
@ -447,7 +451,6 @@ class FastMCPOpenAPI(FastMCP):
client: httpx.AsyncClient,
name: str | None = None,
route_maps: list[RouteMap] | None = None,
default_mime_type: str = "application/json",
**settings: Any,
):
"""
@ -464,7 +467,6 @@ class FastMCPOpenAPI(FastMCP):
super().__init__(name=name or "OpenAPI FastMCP", **settings)
self._client = client
self._default_mime_type = default_mime_type
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
@ -547,7 +549,6 @@ class FastMCPOpenAPI(FastMCP):
uri=resource_uri,
name=resource_name,
description=enhanced_description,
mime_type=self._default_mime_type,
)
# Register the resource by directly assigning to the resources dictionary
self._resource_manager._resources[str(resource.uri)] = resource
@ -621,53 +622,3 @@ class FastMCPOpenAPI(FastMCP):
pass
return result
# Function metadata utility
def func_metadata(fn):
"""Function to generate metadata for a function."""
return mcp_func_metadata(fn)
# Placeholder function to provide function metadata
async def _openapi_passthrough(*args, **kwargs):
"""Placeholder function for OpenAPI endpoints."""
# This is kept for metadata generation purposes
pass
def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
"""
Combines parameter and request body schemas into a single schema.
Args:
route: HTTPRoute object
Returns:
Combined schema dictionary
"""
properties = {}
required = []
# Add path parameters
for param in route.parameters:
if param.required:
required.append(param.name)
properties[param.name] = param.schema_
# Add request body if it exists
if route.request_body and route.request_body.content_schema:
# For now, just use the first content type's schema
content_type = next(iter(route.request_body.content_schema))
body_schema = route.request_body.content_schema[content_type]
body_props = body_schema.get("properties", {})
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", []))
return {
"type": "object",
"properties": properties,
"required": required,
}

View file

@ -14,8 +14,10 @@ from itertools import chain
from typing import TYPE_CHECKING, Any, Generic, Literal
import anyio
import httpx
import pydantic_core
import uvicorn
from fastapi import FastAPI
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.lowlevel.server import Server as MCPServer
@ -52,6 +54,7 @@ from fastmcp.utilities.types import Image
if TYPE_CHECKING:
from fastmcp.clients.base import BaseClient
from fastmcp.server.context import Context
from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.proxy import FastMCPProxy
logger = get_logger(__name__)
@ -565,6 +568,36 @@ class FastMCP(Generic[LifespanResultT]):
return await FastMCPProxy.from_client(client=client, **settings)
@classmethod
def from_openapi(
cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
) -> "FastMCPOpenAPI":
"""
Create a FastMCP server from an OpenAPI specification.
"""
from .openapi import FastMCPOpenAPI
return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
@classmethod
def from_fastapi(
cls, app: FastAPI, name: str | None = None, **settings: Any
) -> "FastMCPOpenAPI":
"""
Create a FastMCP server from a FastAPI application.
"""
from .openapi import FastMCPOpenAPI
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
)
name = name or app.title
return FastMCPOpenAPI(
openapi_spec=app.openapi(), client=client, name=name, **settings
)
def _convert_to_content(
result: Any,

View file

@ -16,6 +16,8 @@ from openapi_pydantic import (
)
from pydantic import BaseModel, Field, ValidationError
from fastmcp.utilities import openapi
logger = logging.getLogger(__name__)
# --- Intermediate Representation (IR) Definition ---
@ -756,3 +758,40 @@ def format_description_with_responses(
)
return "\n".join(desc_parts)
def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
"""
Combines parameter and request body schemas into a single schema.
Args:
route: HTTPRoute object
Returns:
Combined schema dictionary
"""
properties = {}
required = []
# Add path parameters
for param in route.parameters:
if param.required:
required.append(param.name)
properties[param.name] = param.schema_
# Add request body if it exists
if route.request_body and route.request_body.content_schema:
# For now, just use the first content type's schema
content_type = next(iter(route.request_body.content_schema))
body_schema = route.request_body.content_schema[content_type]
body_props = body_schema.get("properties", {})
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", []))
return {
"type": "object",
"properties": properties,
"required": required,
}

View file

@ -34,7 +34,7 @@ def users_db() -> dict[int, User]:
@pytest.fixture
def fastapi_app(users_db: dict[int, User]) -> FastAPI:
app = FastAPI(name="Test App")
app = FastAPI(title="FastAPI App")
@app.get("/users")
async def get_users() -> list[User]:
@ -98,6 +98,20 @@ async def test_create_openapi_server(
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"
class TestTools:
async def test_list_tools(self, fastmcp_server: FastMCPOpenAPI):
"""