Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
dc0a2a3
1
Parent(s): 96618d0
Add fastapi conversion
Browse files- src/fastmcp/server/openapi.py +12 -61
- src/fastmcp/server/server.py +33 -0
- src/fastmcp/utilities/openapi.py +39 -0
- tests/server/test_openapi.py +15 -1
src/fastmcp/server/openapi.py
CHANGED
|
@@ -13,18 +13,15 @@ from fastmcp.resources import Resource, ResourceTemplate
|
|
| 13 |
from fastmcp.server.server import FastMCP
|
| 14 |
from fastmcp.tools.base import Tool
|
| 15 |
from fastmcp.utilities import openapi
|
| 16 |
-
from fastmcp.utilities.func_metadata import
|
| 17 |
-
func_metadata as mcp_func_metadata,
|
| 18 |
-
)
|
| 19 |
from fastmcp.utilities.logging import get_logger
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
logger = get_logger(__name__)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
# HTTP Methods as a Literal for type checking
|
| 28 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 29 |
|
| 30 |
|
|
@@ -98,6 +95,13 @@ def _determine_route_type(
|
|
| 98 |
return RouteType.TOOL
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
class OpenAPITool(Tool):
|
| 102 |
"""Tool implementation for OpenAPI endpoints."""
|
| 103 |
|
|
@@ -447,7 +451,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 447 |
client: httpx.AsyncClient,
|
| 448 |
name: str | None = None,
|
| 449 |
route_maps: list[RouteMap] | None = None,
|
| 450 |
-
default_mime_type: str = "application/json",
|
| 451 |
**settings: Any,
|
| 452 |
):
|
| 453 |
"""
|
|
@@ -464,7 +467,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 464 |
super().__init__(name=name or "OpenAPI FastMCP", **settings)
|
| 465 |
|
| 466 |
self._client = client
|
| 467 |
-
self._default_mime_type = default_mime_type
|
| 468 |
|
| 469 |
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
| 470 |
|
|
@@ -547,7 +549,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 547 |
uri=resource_uri,
|
| 548 |
name=resource_name,
|
| 549 |
description=enhanced_description,
|
| 550 |
-
mime_type=self._default_mime_type,
|
| 551 |
)
|
| 552 |
# Register the resource by directly assigning to the resources dictionary
|
| 553 |
self._resource_manager._resources[str(resource.uri)] = resource
|
|
@@ -621,53 +622,3 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 621 |
pass
|
| 622 |
|
| 623 |
return result
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
# Function metadata utility
|
| 627 |
-
def func_metadata(fn):
|
| 628 |
-
"""Function to generate metadata for a function."""
|
| 629 |
-
return mcp_func_metadata(fn)
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
# Placeholder function to provide function metadata
|
| 633 |
-
async def _openapi_passthrough(*args, **kwargs):
|
| 634 |
-
"""Placeholder function for OpenAPI endpoints."""
|
| 635 |
-
# This is kept for metadata generation purposes
|
| 636 |
-
pass
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
|
| 640 |
-
"""
|
| 641 |
-
Combines parameter and request body schemas into a single schema.
|
| 642 |
-
|
| 643 |
-
Args:
|
| 644 |
-
route: HTTPRoute object
|
| 645 |
-
|
| 646 |
-
Returns:
|
| 647 |
-
Combined schema dictionary
|
| 648 |
-
"""
|
| 649 |
-
properties = {}
|
| 650 |
-
required = []
|
| 651 |
-
|
| 652 |
-
# Add path parameters
|
| 653 |
-
for param in route.parameters:
|
| 654 |
-
if param.required:
|
| 655 |
-
required.append(param.name)
|
| 656 |
-
properties[param.name] = param.schema_
|
| 657 |
-
|
| 658 |
-
# Add request body if it exists
|
| 659 |
-
if route.request_body and route.request_body.content_schema:
|
| 660 |
-
# For now, just use the first content type's schema
|
| 661 |
-
content_type = next(iter(route.request_body.content_schema))
|
| 662 |
-
body_schema = route.request_body.content_schema[content_type]
|
| 663 |
-
body_props = body_schema.get("properties", {})
|
| 664 |
-
for prop_name, prop_schema in body_props.items():
|
| 665 |
-
properties[prop_name] = prop_schema
|
| 666 |
-
if route.request_body.required:
|
| 667 |
-
required.extend(body_schema.get("required", []))
|
| 668 |
-
|
| 669 |
-
return {
|
| 670 |
-
"type": "object",
|
| 671 |
-
"properties": properties,
|
| 672 |
-
"required": required,
|
| 673 |
-
}
|
|
|
|
| 13 |
from fastmcp.server.server import FastMCP
|
| 14 |
from fastmcp.tools.base import Tool
|
| 15 |
from fastmcp.utilities import openapi
|
| 16 |
+
from fastmcp.utilities.func_metadata import func_metadata
|
|
|
|
|
|
|
| 17 |
from fastmcp.utilities.logging import get_logger
|
| 18 |
+
from fastmcp.utilities.openapi import (
|
| 19 |
+
_combine_schemas,
|
| 20 |
+
format_description_with_responses,
|
| 21 |
+
)
|
| 22 |
|
| 23 |
logger = get_logger(__name__)
|
| 24 |
|
|
|
|
|
|
|
| 25 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 26 |
|
| 27 |
|
|
|
|
| 95 |
return RouteType.TOOL
|
| 96 |
|
| 97 |
|
| 98 |
+
# Placeholder function to provide function metadata
|
| 99 |
+
async def _openapi_passthrough(*args, **kwargs):
|
| 100 |
+
"""Placeholder function for OpenAPI endpoints."""
|
| 101 |
+
# This is kept for metadata generation purposes
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
|
| 105 |
class OpenAPITool(Tool):
|
| 106 |
"""Tool implementation for OpenAPI endpoints."""
|
| 107 |
|
|
|
|
| 451 |
client: httpx.AsyncClient,
|
| 452 |
name: str | None = None,
|
| 453 |
route_maps: list[RouteMap] | None = None,
|
|
|
|
| 454 |
**settings: Any,
|
| 455 |
):
|
| 456 |
"""
|
|
|
|
| 467 |
super().__init__(name=name or "OpenAPI FastMCP", **settings)
|
| 468 |
|
| 469 |
self._client = client
|
|
|
|
| 470 |
|
| 471 |
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
| 472 |
|
|
|
|
| 549 |
uri=resource_uri,
|
| 550 |
name=resource_name,
|
| 551 |
description=enhanced_description,
|
|
|
|
| 552 |
)
|
| 553 |
# Register the resource by directly assigning to the resources dictionary
|
| 554 |
self._resource_manager._resources[str(resource.uri)] = resource
|
|
|
|
| 622 |
pass
|
| 623 |
|
| 624 |
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server/server.py
CHANGED
|
@@ -14,8 +14,10 @@ from itertools import chain
|
|
| 14 |
from typing import TYPE_CHECKING, Any, Generic, Literal
|
| 15 |
|
| 16 |
import anyio
|
|
|
|
| 17 |
import pydantic_core
|
| 18 |
import uvicorn
|
|
|
|
| 19 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 20 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 21 |
from mcp.server.lowlevel.server import Server as MCPServer
|
|
@@ -52,6 +54,7 @@ from fastmcp.utilities.types import Image
|
|
| 52 |
if TYPE_CHECKING:
|
| 53 |
from fastmcp.clients.base import BaseClient
|
| 54 |
from fastmcp.server.context import Context
|
|
|
|
| 55 |
from fastmcp.server.proxy import FastMCPProxy
|
| 56 |
|
| 57 |
logger = get_logger(__name__)
|
|
@@ -565,6 +568,36 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 565 |
|
| 566 |
return await FastMCPProxy.from_client(client=client, **settings)
|
| 567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
def _convert_to_content(
|
| 570 |
result: Any,
|
|
|
|
| 14 |
from typing import TYPE_CHECKING, Any, Generic, Literal
|
| 15 |
|
| 16 |
import anyio
|
| 17 |
+
import httpx
|
| 18 |
import pydantic_core
|
| 19 |
import uvicorn
|
| 20 |
+
from fastapi import FastAPI
|
| 21 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 22 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 23 |
from mcp.server.lowlevel.server import Server as MCPServer
|
|
|
|
| 54 |
if TYPE_CHECKING:
|
| 55 |
from fastmcp.clients.base import BaseClient
|
| 56 |
from fastmcp.server.context import Context
|
| 57 |
+
from fastmcp.server.openapi import FastMCPOpenAPI
|
| 58 |
from fastmcp.server.proxy import FastMCPProxy
|
| 59 |
|
| 60 |
logger = get_logger(__name__)
|
|
|
|
| 568 |
|
| 569 |
return await FastMCPProxy.from_client(client=client, **settings)
|
| 570 |
|
| 571 |
+
@classmethod
|
| 572 |
+
def from_openapi(
|
| 573 |
+
cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
|
| 574 |
+
) -> "FastMCPOpenAPI":
|
| 575 |
+
"""
|
| 576 |
+
Create a FastMCP server from an OpenAPI specification.
|
| 577 |
+
"""
|
| 578 |
+
from .openapi import FastMCPOpenAPI
|
| 579 |
+
|
| 580 |
+
return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
|
| 581 |
+
|
| 582 |
+
@classmethod
|
| 583 |
+
def from_fastapi(
|
| 584 |
+
cls, app: FastAPI, name: str | None = None, **settings: Any
|
| 585 |
+
) -> "FastMCPOpenAPI":
|
| 586 |
+
"""
|
| 587 |
+
Create a FastMCP server from a FastAPI application.
|
| 588 |
+
"""
|
| 589 |
+
from .openapi import FastMCPOpenAPI
|
| 590 |
+
|
| 591 |
+
client = httpx.AsyncClient(
|
| 592 |
+
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
name = name or app.title
|
| 596 |
+
|
| 597 |
+
return FastMCPOpenAPI(
|
| 598 |
+
openapi_spec=app.openapi(), client=client, name=name, **settings
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
|
| 602 |
def _convert_to_content(
|
| 603 |
result: Any,
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -16,6 +16,8 @@ from openapi_pydantic import (
|
|
| 16 |
)
|
| 17 |
from pydantic import BaseModel, Field, ValidationError
|
| 18 |
|
|
|
|
|
|
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
# --- Intermediate Representation (IR) Definition ---
|
|
@@ -756,3 +758,40 @@ def format_description_with_responses(
|
|
| 756 |
)
|
| 757 |
|
| 758 |
return "\n".join(desc_parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
from pydantic import BaseModel, Field, ValidationError
|
| 18 |
|
| 19 |
+
from fastmcp.utilities import openapi
|
| 20 |
+
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
# --- Intermediate Representation (IR) Definition ---
|
|
|
|
| 758 |
)
|
| 759 |
|
| 760 |
return "\n".join(desc_parts)
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
|
| 764 |
+
"""
|
| 765 |
+
Combines parameter and request body schemas into a single schema.
|
| 766 |
+
|
| 767 |
+
Args:
|
| 768 |
+
route: HTTPRoute object
|
| 769 |
+
|
| 770 |
+
Returns:
|
| 771 |
+
Combined schema dictionary
|
| 772 |
+
"""
|
| 773 |
+
properties = {}
|
| 774 |
+
required = []
|
| 775 |
+
|
| 776 |
+
# Add path parameters
|
| 777 |
+
for param in route.parameters:
|
| 778 |
+
if param.required:
|
| 779 |
+
required.append(param.name)
|
| 780 |
+
properties[param.name] = param.schema_
|
| 781 |
+
|
| 782 |
+
# Add request body if it exists
|
| 783 |
+
if route.request_body and route.request_body.content_schema:
|
| 784 |
+
# For now, just use the first content type's schema
|
| 785 |
+
content_type = next(iter(route.request_body.content_schema))
|
| 786 |
+
body_schema = route.request_body.content_schema[content_type]
|
| 787 |
+
body_props = body_schema.get("properties", {})
|
| 788 |
+
for prop_name, prop_schema in body_props.items():
|
| 789 |
+
properties[prop_name] = prop_schema
|
| 790 |
+
if route.request_body.required:
|
| 791 |
+
required.extend(body_schema.get("required", []))
|
| 792 |
+
|
| 793 |
+
return {
|
| 794 |
+
"type": "object",
|
| 795 |
+
"properties": properties,
|
| 796 |
+
"required": required,
|
| 797 |
+
}
|
tests/server/test_openapi.py
CHANGED
|
@@ -34,7 +34,7 @@ def users_db() -> dict[int, User]:
|
|
| 34 |
|
| 35 |
@pytest.fixture
|
| 36 |
def fastapi_app(users_db: dict[int, User]) -> FastAPI:
|
| 37 |
-
app = FastAPI(
|
| 38 |
|
| 39 |
@app.get("/users")
|
| 40 |
async def get_users() -> list[User]:
|
|
@@ -98,6 +98,20 @@ async def test_create_openapi_server(
|
|
| 98 |
assert server.name == "Test App"
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
class TestTools:
|
| 102 |
async def test_list_tools(self, fastmcp_server: FastMCPOpenAPI):
|
| 103 |
"""
|
|
|
|
| 34 |
|
| 35 |
@pytest.fixture
|
| 36 |
def fastapi_app(users_db: dict[int, User]) -> FastAPI:
|
| 37 |
+
app = FastAPI(title="FastAPI App")
|
| 38 |
|
| 39 |
@app.get("/users")
|
| 40 |
async def get_users() -> list[User]:
|
|
|
|
| 98 |
assert server.name == "Test App"
|
| 99 |
|
| 100 |
|
| 101 |
+
async def test_create_openapi_server_classmethod(
|
| 102 |
+
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 103 |
+
):
|
| 104 |
+
server = FastMCP.from_openapi(openapi_spec=fastapi_app.openapi(), client=api_client)
|
| 105 |
+
assert isinstance(server, FastMCPOpenAPI)
|
| 106 |
+
assert server.name == "OpenAPI FastMCP"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
|
| 110 |
+
server = FastMCP.from_fastapi(fastapi_app)
|
| 111 |
+
assert isinstance(server, FastMCPOpenAPI)
|
| 112 |
+
assert server.name == "FastAPI App"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
class TestTools:
|
| 116 |
async def test_list_tools(self, fastmcp_server: FastMCPOpenAPI):
|
| 117 |
"""
|