diff --git a/README.md b/README.md index c5b2c24..0c54ce7 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,30 @@ mcp.mount() That's it! Your auto-generated MCP server is now available at `https://app.base.url/mcp`. +## Remote OpenAPI Schema + +You can configure FastApiMCP to fetch the OpenAPI schema from a remote FastAPI server by providing a custom httpx.AsyncClient and setting `fetch_openapi_from_remote=True`: + +```python +import httpx +from fastapi import FastAPI +from fastapi_mcp import FastApiMCP + +REMOTE_API_URL = "http://127.0.0.1:5000" +app = FastAPI() +client = httpx.AsyncClient(base_url=REMOTE_API_URL) + +mcp = FastApiMCP( + app, + name="MCP for Remote API", + http_client=client, + fetch_openapi_from_remote=True, +) +mcp.mount() +``` + +If `fetch_openapi_from_remote` is set and a custom client is provided, FastApiMCP will retrieve the OpenAPI schema from the remote server's `/openapi.json` endpoint instead of generating it locally. + ## Documentation, Examples and Advanced Usage FastAPI-MCP provides [comprehensive documentation](https://fastapi-mcp.tadata.com/). Additionaly, check out the [examples directory](examples) for code samples demonstrating these features in action. diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index f5c4fc6..2626c0b 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -93,6 +93,14 @@ def __init__( """ ), ] = None, + fetch_openapi_from_remote: Annotated[ + bool, + Doc( + """ + If True and http_client is provided, fetch OpenAPI schema from remote /openapi.json instead of generating locally. + """ + ), + ] = False, include_operations: Annotated[ Optional[List[str]], Doc("List of operation IDs to include as MCP tools. Cannot be used with exclude_operations."), @@ -137,6 +145,7 @@ def __init__( self._include_tags = include_tags self._exclude_tags = exclude_tags self._auth_config = auth_config + self._fetch_openapi_from_remote = fetch_openapi_from_remote if self._auth_config: self._auth_config = self._auth_config.model_validate(self._auth_config) @@ -150,13 +159,29 @@ def __init__( self.setup_server() def setup_server(self) -> None: - openapi_schema = get_openapi( - title=self.fastapi.title, - version=self.fastapi.version, - openapi_version=self.fastapi.openapi_version, - description=self.fastapi.description, - routes=self.fastapi.routes, - ) + import asyncio + + openapi_schema = None + if self.fetch_openapi_from_remote and self._http_client: + + async def fetch_openapi(): + openapi_url = getattr(self.fastapi, "openapi_url", "/openapi.json") + resp = await self._http_client.get(openapi_url) + resp.raise_for_status() + return resp.json() + + try: + openapi_schema = asyncio.get_event_loop().run_until_complete(fetch_openapi()) + except Exception as e: + logger.error(f"Failed to fetch remote OpenAPI schema: {e}. Falling back to local generation.") + if openapi_schema is None: + openapi_schema = get_openapi( + title=self.fastapi.title, + version=self.fastapi.version, + openapi_version=self.fastapi.openapi_version, + description=self.fastapi.description, + routes=self.fastapi.routes, + ) all_tools, self.operation_map = convert_openapi_to_mcp_tools( openapi_schema, diff --git a/tests/test_remote_openapi.py b/tests/test_remote_openapi.py new file mode 100644 index 0000000..e0ddc67 --- /dev/null +++ b/tests/test_remote_openapi.py @@ -0,0 +1,28 @@ +import pytest +from fastapi import FastAPI +from fastapi_mcp import FastApiMCP + + +@pytest.mark.asyncio +def test_fetch_openapi_from_remote(monkeypatch): + app = FastAPI() + remote_openapi = {"openapi": "3.0.0", "info": {"title": "Remote", "version": "1.0.0"}, "paths": {}} + + class MockAsyncClient: + async def get(self, url): + class Resp: + def raise_for_status(self): + pass + + def json(self): + return remote_openapi + + return Resp() + + client = MockAsyncClient() + mcp = FastApiMCP(app, http_client=client, fetch_openapi_from_remote=True) + # The openapi schema should be fetched from remote + assert mcp.tools == [] # tools should be an empty list since paths is empty + assert mcp.operation_map is not None + # The schema should match what the mock returned + assert mcp.operation_map == {} # since paths is empty