From 8720a787473bd8c86bc19c47fa7e95fe9201a7e5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:25:44 -0400 Subject: [PATCH] Add fastapi conversion --- src/fastmcp/server/openapi.py | 73 ++++++-------------------------- src/fastmcp/server/server.py | 33 +++++++++++++++ src/fastmcp/utilities/openapi.py | 39 +++++++++++++++++ tests/server/test_openapi.py | 16 ++++++- 4 files changed, 99 insertions(+), 62 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index e2eeddf37..49d55dff1 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -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, - } diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d709b1f61..772d07869 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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, diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index c8524f2db..68abf0126 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -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, + } diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 16def53fe..35e9e9ab3 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -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): """