diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ff9caa9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "monthly" diff --git a/.gitignore b/.gitignore index 25b4600..e0c28d4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ test* openapiSchemaExample.json package-lock.json .gitconfig +tasks.md +plan.md +code-review.md +.playwright-mcp \ No newline at end of file diff --git a/ReadMe.md b/ReadMe.md index 5319505..c355747 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,6 +1,71 @@ # openapi-toolkit openapi-toolkit is an open-source tool designed to streamline the integration of OpenAPI (formerly known as Swagger) specifications into your development workflow. By taking an OpenAPI/Swagger file as input, the OpenAPI Toolkit automatically generates server and client code, enabling seamless integration of APIs. This automation accelerates development processes, ensures consistency across different platforms, and reduces the risk of manual errors. Whether you're building a new service or integrating with existing APIs, OpenAPI Toolkit simplifies the process by providing ready-to-use code tailored to your OpenAPI specifications. +# Generate MCP Server + +openapi-toolkit generates a fully functional [MCP SDK FastMCP](https://github.com/modelcontextprotocol/python-sdk) server from any OpenAPI/Swagger spec — one MCP tool per API endpoint, grouped by controller, with the async Pydantic v2 client bundled inline. + +## Generate + +```bash +# no install required +npx openapi-toolkit -i https://petstore3.swagger.io/api/v3/openapi.json -g python-mcp-server -t server -o ./my-api-mcp + +# or with global install +npm i -g openapi-toolkit +openapi-toolkit -i https://petstore3.swagger.io/api/v3/openapi.json -g python-mcp-server -t server -o ./my-api-mcp +``` + +Generated layout: +``` +my-api-mcp/ + pyproject.toml # uv project: mcp, httpx, pydantic + README.md + src/ + mcp_server.py # FastMCP entry point, tool registration, transport CLI + client/ # generated async httpx + Pydantic v2 client (inline) + __init__.py + client.py + models/ + controllers/ + server/ + tools/ + .py # one file per API controller tag +``` + +## Install and run + +```bash +cd my-api-mcp +uv sync +BASE_URL=https://api.example.com uv run python src/mcp_server.py +``` + +## Transport options + +```bash +# stdio (default — for Claude Desktop and most MCP clients) +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport stdio + +# SSE +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport sse --host 0.0.0.0 --port 8000 + +# Streamable HTTP +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport streamable-http --host 0.0.0.0 --port 8000 --path /mcp +``` + +A fully generated example (Petstore API) is available in this repo: [examples/pet-store-mcp](https://github.com/barnuri/openapi-toolkit/tree/master/examples/pet-store-mcp) + +![pet-store-mcp folder structure](/docs/pet-store-mcp-vscode.png?raw=true) + +## Environment variables + +| Variable | Description | +|---|---| +| `BASE_URL` | Base URL of the target API (required at runtime) | +| `TOOL_FILTER_ROUTES` | Comma-separated controller names to load (e.g. `pet,store`). Loads all if unset. | +| `TOOL_FILTER_METHODS` | Comma-separated tool function names to register (e.g. `getPetById,addPet`). Registers all if unset. | + # Install [![Run Tests](https://github.com/barnuri/openapi-toolkit/actions/workflows/runTests.yaml/badge.svg)](https://github.com/barnuri/openapi-toolkit/actions/workflows/runTests.yaml) [![Create Tag And Release And Publish To NPM](https://github.com/barnuri/openapi-toolkit/actions/workflows/createTagAndReleaseAndPublish.yaml/badge.svg)](https://github.com/barnuri/openapi-toolkit/actions/workflows/createTagAndReleaseAndPublish.yaml) diff --git a/ex.png b/docs/ex.png similarity index 100% rename from ex.png rename to docs/ex.png diff --git a/docs/pet-store-mcp-vscode.png b/docs/pet-store-mcp-vscode.png new file mode 100644 index 0000000..4b7c413 Binary files /dev/null and b/docs/pet-store-mcp-vscode.png differ diff --git a/examples/ReadMe.md b/examples/ReadMe.md index c1ef0b6..bc1f6c8 100644 --- a/examples/ReadMe.md +++ b/examples/ReadMe.md @@ -38,4 +38,4 @@ const ex = async () = { ### Result -![Example](https://github.com/barnuri/openapi-toolkit/blob/master/ex.png?raw=true) +![Example](https://github.com/barnuri/openapi-toolkit/blob/master/docs/ex.png?raw=true) diff --git a/examples/pet-store-mcp/README.md b/examples/pet-store-mcp/README.md new file mode 100644 index 0000000..5732722 --- /dev/null +++ b/examples/pet-store-mcp/README.md @@ -0,0 +1,48 @@ +# pet-store-mcp + +MCP server generated by [openapi-toolkit](https://github.com/barnuri/openapi-toolkit). + +## Install + +```bash +uv sync +``` + +## Run + +```bash +# stdio (default — for Claude Desktop and most MCP clients) +BASE_URL=https://api.example.com uv run python src/mcp_server.py + +# SSE +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport sse --host 0.0.0.0 --port 8000 + +# Streamable HTTP +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport streamable-http --host 0.0.0.0 --port 8000 --path /mcp +``` + +## Environment variables + +| Variable | Description | +|---|---| +| `BASE_URL` | Base URL of the target API (required) | +| `TOOL_FILTER_ROUTES` | Comma-separated controller names to load (e.g. `pet,store`). Loads all if unset. | +| `TOOL_FILTER_METHODS` | Comma-separated tool function names to register (e.g. `getPetById,addPet`). Registers all if unset. | + +## CLI options + +``` +--transport stdio | sse | streamable-http (default: stdio) +--host host for HTTP transports (default: 0.0.0.0) +--port port for HTTP transports (default: 8000) +--path path for streamable-http (default: /mcp) +``` + +## Project layout + +``` +src/ + mcp_server.py # entry point — tool registration and transport CLI + client/ # async httpx + Pydantic v2 client (generated inline) + server/tools/ # one file per API controller +``` diff --git a/examples/pet-store-mcp/pyproject.toml b/examples/pet-store-mcp/pyproject.toml new file mode 100644 index 0000000..b12002b --- /dev/null +++ b/examples/pet-store-mcp/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "pet-store-mcp" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.0", + "httpx>=0.27", + "pydantic>=2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/client", "src/server"] + +[project.scripts] +pet-store-mcp = "mcp_server:main" diff --git a/examples/pet-store-mcp/src/__init__.py b/examples/pet-store-mcp/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/pet-store-mcp/src/client/__init__.py b/examples/pet-store-mcp/src/client/__init__.py new file mode 100644 index 0000000..3ff722b --- /dev/null +++ b/examples/pet-store-mcp/src/client/__init__.py @@ -0,0 +1 @@ +from .client import Client diff --git a/examples/pet-store-mcp/src/client/client.py b/examples/pet-store-mcp/src/client/client.py new file mode 100644 index 0000000..be47395 --- /dev/null +++ b/examples/pet-store-mcp/src/client/client.py @@ -0,0 +1,12 @@ +from .controllers.PetController import PetController +from .controllers.StoreController import StoreController +from .controllers.UserController import UserController +import os + + +class Client: + def __init__(self, base_url: str | None = None) -> None: + base_url = base_url or os.environ.get("BASE_URL", "") + self.PetController = PetController(base_url) + self.StoreController = StoreController(base_url) + self.UserController = UserController(base_url) diff --git a/examples/pet-store-mcp/src/client/controllers/PetController.py b/examples/pet-store-mcp/src/client/controllers/PetController.py new file mode 100644 index 0000000..63874ff --- /dev/null +++ b/examples/pet-store-mcp/src/client/controllers/PetController.py @@ -0,0 +1,80 @@ +import httpx +from ..models.Pet import Pet +from ..models.ApiResponse import ApiResponse + +class PetController: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + + async def putPet(self, body: Pet, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.put( + f"{self._base_url}/pet", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postPet(self, body: Pet, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/pet", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getFindByStatus(self, q_status: str | None = None, **kwargs) -> list[Pet]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/pet/findByStatus", + params={k: v for k, v in {"status": q_status}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getFindByTags(self, q_tags: list[str] | None = None, **kwargs) -> list[Pet]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/pet/findByTags", + params={k: v for k, v in {"tags": q_tags}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getPetId(self, p_pet_id: int | None, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/pet/{p_pet_id}", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postPetId(self, p_pet_id: int | None, q_name: str | None = None, q_status: str | None = None, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/pet/{p_pet_id}", + params={k: v for k, v in {"name": q_name, "status": q_status}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def deletePetId(self, p_pet_id: int | None, h_apikey: str | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self._base_url}/pet/{p_pet_id}", + headers={'api_key': h_apikey}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postPetIdUploadImage(self, body: str | None, p_pet_id: int | None, q_additional_metadata: str | None = None, **kwargs) -> ApiResponse: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/pet/{p_pet_id}/uploadImage", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + params={k: v for k, v in {"additionalMetadata": q_additional_metadata}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/controllers/StoreController.py b/examples/pet-store-mcp/src/client/controllers/StoreController.py new file mode 100644 index 0000000..c9ce64b --- /dev/null +++ b/examples/pet-store-mcp/src/client/controllers/StoreController.py @@ -0,0 +1,40 @@ +import httpx +from ..models.Order import Order + +class StoreController: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + + async def getInventory(self, **kwargs) -> dict[str, int]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/store/inventory", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postOrder(self, body: Order | None, **kwargs) -> Order: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/store/order", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getOrderOrderId(self, p_order_id: int | None, **kwargs) -> Order: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/store/order/{p_order_id}", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def deleteOrderOrderId(self, p_order_id: int | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self._base_url}/store/order/{p_order_id}", + **kwargs, + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/controllers/UserController.py b/examples/pet-store-mcp/src/client/controllers/UserController.py new file mode 100644 index 0000000..95fc424 --- /dev/null +++ b/examples/pet-store-mcp/src/client/controllers/UserController.py @@ -0,0 +1,67 @@ +import httpx +from ..models.User import User + +class UserController: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + + async def postUser(self, body: User | None, **kwargs) -> User: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/user", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postCreateWithList(self, body: list[User] | None, **kwargs) -> User: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/user/createWithList", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getLogin(self, q_username: str | None = None, q_password: str | None = None, **kwargs) -> str: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/user/login", + params={k: v for k, v in {"username": q_username, "password": q_password}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getLogout(self, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/user/logout", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getUsername(self, p_username: str | None, **kwargs) -> User: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/user/{p_username}", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def putUsername(self, body: User | None, p_username: str | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.put( + f"{self._base_url}/user/{p_username}", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def deleteUsername(self, p_username: str | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self._base_url}/user/{p_username}", + **kwargs, + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/controllers/__init__.py b/examples/pet-store-mcp/src/client/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/pet-store-mcp/src/client/models/ApiResponse.py b/examples/pet-store-mcp/src/client/models/ApiResponse.py new file mode 100644 index 0000000..7f7a584 --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/ApiResponse.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, ConfigDict + +class ApiResponse(BaseModel): + code: int | None = None + type: str | None = None + message: str | None = None \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/Category.py b/examples/pet-store-mcp/src/client/models/Category.py new file mode 100644 index 0000000..523354d --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/Category.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, ConfigDict + +class Category(BaseModel): + id: int | None = None + name: str | None = None \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/Order.py b/examples/pet-store-mcp/src/client/models/Order.py new file mode 100644 index 0000000..2193ee0 --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/Order.py @@ -0,0 +1,11 @@ +from ..models.StatusEnum import StatusEnum +from datetime import datetime +from pydantic import BaseModel, ConfigDict + +class Order(BaseModel): + id: int | None = None + petId: int | None = None + quantity: int | None = None + shipDate: datetime | None = None + status: StatusEnum | None = None + complete: bool | None = None \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/Pet.py b/examples/pet-store-mcp/src/client/models/Pet.py new file mode 100644 index 0000000..1752d58 --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/Pet.py @@ -0,0 +1,12 @@ +from ..models.Category import Category +from ..models.Tag import Tag +from ..models.StatusEnum import StatusEnum +from pydantic import BaseModel, ConfigDict + +class Pet(BaseModel): + id: int | None = None + name: str = None + category: Category | None = None + photoUrls: list[str] | None = None + tags: list[Tag] | None = None + status: StatusEnum | None = None \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/Status.py b/examples/pet-store-mcp/src/client/models/Status.py new file mode 100644 index 0000000..1366cfe --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/Status.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class Status(str, Enum): + placed = 'placed' + approved = 'approved' + delivered = 'delivered' \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/StatusEnum.py b/examples/pet-store-mcp/src/client/models/StatusEnum.py new file mode 100644 index 0000000..2a25f82 --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/StatusEnum.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class StatusEnum(str, Enum): + placed = 'placed' + approved = 'approved' + delivered = 'delivered' \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/Tag.py b/examples/pet-store-mcp/src/client/models/Tag.py new file mode 100644 index 0000000..27152d5 --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/Tag.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, ConfigDict + +class Tag(BaseModel): + id: int | None = None + name: str | None = None \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/User.py b/examples/pet-store-mcp/src/client/models/User.py new file mode 100644 index 0000000..fd809a1 --- /dev/null +++ b/examples/pet-store-mcp/src/client/models/User.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict + +class User(BaseModel): + id: int | None = None + username: str | None = None + firstName: str | None = None + lastName: str | None = None + email: str | None = None + password: str | None = None + phone: str | None = None + userStatus: int | None = None \ No newline at end of file diff --git a/examples/pet-store-mcp/src/client/models/__init__.py b/examples/pet-store-mcp/src/client/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/pet-store-mcp/src/mcp_server.py b/examples/pet-store-mcp/src/mcp_server.py new file mode 100644 index 0000000..eca5877 --- /dev/null +++ b/examples/pet-store-mcp/src/mcp_server.py @@ -0,0 +1,54 @@ +import importlib +import os +import sys +from functools import lru_cache +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from mcp.server.fastmcp import FastMCP +from client import Client + +mcp = FastMCP("pet-store-mcp") + + +@lru_cache(maxsize=1) +def get_client() -> Client: + return Client(os.environ.get("BASE_URL", "")) + + +_route_filter = [r.strip() for r in os.environ.get("TOOL_FILTER_ROUTES", "").split(",") if r.strip()] +_method_filter = [m.strip() for m in os.environ.get("TOOL_FILTER_METHODS", "").split(",") if m.strip()] + +ALL_TOOLS: list[str] = ["pet_controller", "store_controller", "user_controller"] + +for _module_name in ALL_TOOLS: + if not _route_filter or _module_name in _route_filter: + _module = importlib.import_module(f"server.tools.{_module_name}") + for _tool in _module.TOOLS: + if not _method_filter or _tool.__name__ in _method_filter: + mcp.tool()(_tool) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="pet-store-mcp MCP server") + parser.add_argument("--transport", default="stdio", choices=["stdio", "sse", "streamable-http"], help="Transport type (default: stdio)") + parser.add_argument("--host", default="0.0.0.0", help="Host for HTTP transports (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8000, help="Port for HTTP transports (default: 8000)") + parser.add_argument("--path", default="/mcp", help="Path for streamable-http transport (default: /mcp)") + args = parser.parse_args() + + run_kwargs: dict = {"transport": args.transport} + if args.transport != "stdio": + run_kwargs["host"] = args.host + run_kwargs["port"] = args.port + if args.transport == "streamable-http": + run_kwargs["path"] = args.path + + mcp.run(**run_kwargs) + + +if __name__ == "__main__": + main() diff --git a/examples/pet-store-mcp/src/server/__init__.py b/examples/pet-store-mcp/src/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/pet-store-mcp/src/server/tools/__init__.py b/examples/pet-store-mcp/src/server/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/pet-store-mcp/src/server/tools/pet_controller.py b/examples/pet-store-mcp/src/server/tools/pet_controller.py new file mode 100644 index 0000000..8f3618c --- /dev/null +++ b/examples/pet-store-mcp/src/server/tools/pet_controller.py @@ -0,0 +1,52 @@ +from mcp_server import get_client +from client.models.Pet import Pet + +async def putPet(body: Pet, ) -> dict: + """PUT /pet""" + client = get_client() + return await client.PetController.putPet(body=body) + + +async def postPet(body: Pet, ) -> dict: + """POST /pet""" + client = get_client() + return await client.PetController.postPet(body=body) + + +async def getFindByStatus(q_status: str | None = None, ) -> dict: + """GET /pet/findByStatus""" + client = get_client() + return await client.PetController.getFindByStatus(q_status=q_status) + + +async def getFindByTags(q_tags: list[str] | None = None, ) -> dict: + """GET /pet/findByTags""" + client = get_client() + return await client.PetController.getFindByTags(q_tags=q_tags) + + +async def getPetId(p_pet_id: int | None, ) -> dict: + """GET /pet/{petId}""" + client = get_client() + return await client.PetController.getPetId(p_pet_id=p_pet_id) + + +async def postPetId(p_pet_id: int | None, q_name: str | None = None, q_status: str | None = None, ) -> dict: + """POST /pet/{petId}""" + client = get_client() + return await client.PetController.postPetId(p_pet_id=p_pet_id, q_name=q_name, q_status=q_status) + + +async def deletePetId(p_pet_id: int | None, h_apikey: str | None, ) -> dict: + """DELETE /pet/{petId}""" + client = get_client() + return await client.PetController.deletePetId(p_pet_id=p_pet_id, h_apikey=h_apikey) + + +async def postPetIdUploadImage(body: str | None, p_pet_id: int | None, q_additional_metadata: str | None = None, ) -> dict: + """POST /pet/{petId}/uploadImage""" + client = get_client() + return await client.PetController.postPetIdUploadImage(body=body, p_pet_id=p_pet_id, q_additional_metadata=q_additional_metadata) + + +TOOLS = [putPet, postPet, getFindByStatus, getFindByTags, getPetId, postPetId, deletePetId, postPetIdUploadImage] diff --git a/examples/pet-store-mcp/src/server/tools/store_controller.py b/examples/pet-store-mcp/src/server/tools/store_controller.py new file mode 100644 index 0000000..7cd3c1d --- /dev/null +++ b/examples/pet-store-mcp/src/server/tools/store_controller.py @@ -0,0 +1,28 @@ +from mcp_server import get_client +from client.models.Order import Order + +async def getInventory() -> dict: + """GET /store/inventory""" + client = get_client() + return await client.StoreController.getInventory() + + +async def postOrder(body: Order | None, ) -> dict: + """POST /store/order""" + client = get_client() + return await client.StoreController.postOrder(body=body) + + +async def getOrderOrderId(p_order_id: int | None, ) -> dict: + """GET /store/order/{orderId}""" + client = get_client() + return await client.StoreController.getOrderOrderId(p_order_id=p_order_id) + + +async def deleteOrderOrderId(p_order_id: int | None, ) -> dict: + """DELETE /store/order/{orderId}""" + client = get_client() + return await client.StoreController.deleteOrderOrderId(p_order_id=p_order_id) + + +TOOLS = [getInventory, postOrder, getOrderOrderId, deleteOrderOrderId] diff --git a/examples/pet-store-mcp/src/server/tools/user_controller.py b/examples/pet-store-mcp/src/server/tools/user_controller.py new file mode 100644 index 0000000..80439da --- /dev/null +++ b/examples/pet-store-mcp/src/server/tools/user_controller.py @@ -0,0 +1,46 @@ +from mcp_server import get_client +from client.models.User import User + +async def postUser(body: User | None, ) -> dict: + """POST /user""" + client = get_client() + return await client.UserController.postUser(body=body) + + +async def postCreateWithList(body: list[User] | None, ) -> dict: + """POST /user/createWithList""" + client = get_client() + return await client.UserController.postCreateWithList(body=body) + + +async def getLogin(q_username: str | None = None, q_password: str | None = None, ) -> dict: + """GET /user/login""" + client = get_client() + return await client.UserController.getLogin(q_username=q_username, q_password=q_password) + + +async def getLogout() -> dict: + """GET /user/logout""" + client = get_client() + return await client.UserController.getLogout() + + +async def getUsername(p_username: str | None, ) -> dict: + """GET /user/{username}""" + client = get_client() + return await client.UserController.getUsername(p_username=p_username) + + +async def putUsername(body: User | None, p_username: str | None, ) -> dict: + """PUT /user/{username}""" + client = get_client() + return await client.UserController.putUsername(body=body, p_username=p_username) + + +async def deleteUsername(p_username: str | None, ) -> dict: + """DELETE /user/{username}""" + client = get_client() + return await client.UserController.deleteUsername(p_username=p_username) + + +TOOLS = [postUser, postCreateWithList, getLogin, getLogout, getUsername, putUsername, deleteUsername] diff --git a/examples/python-pydantic-client/pyproject.toml b/examples/python-pydantic-client/pyproject.toml new file mode 100644 index 0000000..229d1b4 --- /dev/null +++ b/examples/python-pydantic-client/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "python-pydantic-client" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27", + "pydantic>=2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/client"] diff --git a/examples/python-pydantic-client/src/client/__init__.py b/examples/python-pydantic-client/src/client/__init__.py new file mode 100644 index 0000000..3ff722b --- /dev/null +++ b/examples/python-pydantic-client/src/client/__init__.py @@ -0,0 +1 @@ +from .client import Client diff --git a/examples/python-pydantic-client/src/client/client.py b/examples/python-pydantic-client/src/client/client.py new file mode 100644 index 0000000..be47395 --- /dev/null +++ b/examples/python-pydantic-client/src/client/client.py @@ -0,0 +1,12 @@ +from .controllers.PetController import PetController +from .controllers.StoreController import StoreController +from .controllers.UserController import UserController +import os + + +class Client: + def __init__(self, base_url: str | None = None) -> None: + base_url = base_url or os.environ.get("BASE_URL", "") + self.PetController = PetController(base_url) + self.StoreController = StoreController(base_url) + self.UserController = UserController(base_url) diff --git a/examples/python-pydantic-client/src/client/controllers/PetController.py b/examples/python-pydantic-client/src/client/controllers/PetController.py new file mode 100644 index 0000000..63874ff --- /dev/null +++ b/examples/python-pydantic-client/src/client/controllers/PetController.py @@ -0,0 +1,80 @@ +import httpx +from ..models.Pet import Pet +from ..models.ApiResponse import ApiResponse + +class PetController: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + + async def putPet(self, body: Pet, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.put( + f"{self._base_url}/pet", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postPet(self, body: Pet, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/pet", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getFindByStatus(self, q_status: str | None = None, **kwargs) -> list[Pet]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/pet/findByStatus", + params={k: v for k, v in {"status": q_status}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getFindByTags(self, q_tags: list[str] | None = None, **kwargs) -> list[Pet]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/pet/findByTags", + params={k: v for k, v in {"tags": q_tags}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getPetId(self, p_pet_id: int | None, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/pet/{p_pet_id}", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postPetId(self, p_pet_id: int | None, q_name: str | None = None, q_status: str | None = None, **kwargs) -> Pet: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/pet/{p_pet_id}", + params={k: v for k, v in {"name": q_name, "status": q_status}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def deletePetId(self, p_pet_id: int | None, h_apikey: str | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self._base_url}/pet/{p_pet_id}", + headers={'api_key': h_apikey}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postPetIdUploadImage(self, body: str | None, p_pet_id: int | None, q_additional_metadata: str | None = None, **kwargs) -> ApiResponse: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/pet/{p_pet_id}/uploadImage", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + params={k: v for k, v in {"additionalMetadata": q_additional_metadata}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/controllers/StoreController.py b/examples/python-pydantic-client/src/client/controllers/StoreController.py new file mode 100644 index 0000000..c9ce64b --- /dev/null +++ b/examples/python-pydantic-client/src/client/controllers/StoreController.py @@ -0,0 +1,40 @@ +import httpx +from ..models.Order import Order + +class StoreController: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + + async def getInventory(self, **kwargs) -> dict[str, int]: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/store/inventory", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postOrder(self, body: Order | None, **kwargs) -> Order: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/store/order", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getOrderOrderId(self, p_order_id: int | None, **kwargs) -> Order: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/store/order/{p_order_id}", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def deleteOrderOrderId(self, p_order_id: int | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self._base_url}/store/order/{p_order_id}", + **kwargs, + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/controllers/UserController.py b/examples/python-pydantic-client/src/client/controllers/UserController.py new file mode 100644 index 0000000..95fc424 --- /dev/null +++ b/examples/python-pydantic-client/src/client/controllers/UserController.py @@ -0,0 +1,67 @@ +import httpx +from ..models.User import User + +class UserController: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + + async def postUser(self, body: User | None, **kwargs) -> User: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/user", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def postCreateWithList(self, body: list[User] | None, **kwargs) -> User: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self._base_url}/user/createWithList", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getLogin(self, q_username: str | None = None, q_password: str | None = None, **kwargs) -> str: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/user/login", + params={k: v for k, v in {"username": q_username, "password": q_password}.items() if v is not None}, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getLogout(self, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/user/logout", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def getUsername(self, p_username: str | None, **kwargs) -> User: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._base_url}/user/{p_username}", + **kwargs, + ) + response.raise_for_status() + return response.json() + async def putUsername(self, body: User | None, p_username: str | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.put( + f"{self._base_url}/user/{p_username}", + json=body.model_dump() if hasattr(body, 'model_dump') else body, + **kwargs, + ) + response.raise_for_status() + return response.json() + async def deleteUsername(self, p_username: str | None, **kwargs) -> dict: + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self._base_url}/user/{p_username}", + **kwargs, + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/controllers/__init__.py b/examples/python-pydantic-client/src/client/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/python-pydantic-client/src/client/models/ApiResponse.py b/examples/python-pydantic-client/src/client/models/ApiResponse.py new file mode 100644 index 0000000..7f7a584 --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/ApiResponse.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, ConfigDict + +class ApiResponse(BaseModel): + code: int | None = None + type: str | None = None + message: str | None = None \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/Category.py b/examples/python-pydantic-client/src/client/models/Category.py new file mode 100644 index 0000000..523354d --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/Category.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, ConfigDict + +class Category(BaseModel): + id: int | None = None + name: str | None = None \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/Order.py b/examples/python-pydantic-client/src/client/models/Order.py new file mode 100644 index 0000000..2193ee0 --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/Order.py @@ -0,0 +1,11 @@ +from ..models.StatusEnum import StatusEnum +from datetime import datetime +from pydantic import BaseModel, ConfigDict + +class Order(BaseModel): + id: int | None = None + petId: int | None = None + quantity: int | None = None + shipDate: datetime | None = None + status: StatusEnum | None = None + complete: bool | None = None \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/Pet.py b/examples/python-pydantic-client/src/client/models/Pet.py new file mode 100644 index 0000000..1752d58 --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/Pet.py @@ -0,0 +1,12 @@ +from ..models.Category import Category +from ..models.Tag import Tag +from ..models.StatusEnum import StatusEnum +from pydantic import BaseModel, ConfigDict + +class Pet(BaseModel): + id: int | None = None + name: str = None + category: Category | None = None + photoUrls: list[str] | None = None + tags: list[Tag] | None = None + status: StatusEnum | None = None \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/Status.py b/examples/python-pydantic-client/src/client/models/Status.py new file mode 100644 index 0000000..1366cfe --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/Status.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class Status(str, Enum): + placed = 'placed' + approved = 'approved' + delivered = 'delivered' \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/StatusEnum.py b/examples/python-pydantic-client/src/client/models/StatusEnum.py new file mode 100644 index 0000000..2a25f82 --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/StatusEnum.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class StatusEnum(str, Enum): + placed = 'placed' + approved = 'approved' + delivered = 'delivered' \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/Tag.py b/examples/python-pydantic-client/src/client/models/Tag.py new file mode 100644 index 0000000..27152d5 --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/Tag.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, ConfigDict + +class Tag(BaseModel): + id: int | None = None + name: str | None = None \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/User.py b/examples/python-pydantic-client/src/client/models/User.py new file mode 100644 index 0000000..fd809a1 --- /dev/null +++ b/examples/python-pydantic-client/src/client/models/User.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict + +class User(BaseModel): + id: int | None = None + username: str | None = None + firstName: str | None = None + lastName: str | None = None + email: str | None = None + password: str | None = None + phone: str | None = None + userStatus: int | None = None \ No newline at end of file diff --git a/examples/python-pydantic-client/src/client/models/__init__.py b/examples/python-pydantic-client/src/client/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 8251319..cf87d5b 100644 --- a/package.json +++ b/package.json @@ -35,27 +35,30 @@ "test:python": "npm run build && cross-env-shell \"node ./dist/cli-generate.js -i $OPENAPI_TOOLKIT_TESTFILE -g python -o ./test-python --modelNamePrefix My --modelNameSuffix .dto.test\" && python -m pylint --errors-only ./test-python", "test:go": "npm run build && cross-env-shell \"node ./dist/cli-generate.js -i $OPENAPI_TOOLKIT_TESTFILE -g go -o ./test-go\"", "test:go-server": "npm run build && cross-env-shell \"node ./dist/cli-generate.js -i $OPENAPI_TOOLKIT_TESTFILE -g go -o ./test-go-server -t server\"", - "upgradeDep": "npm-check-updates -u" + "upgradeDep": "npm-check-updates -u", + "gen": "npm run gen:python-mcp-example && npm run gen:python-pydantic-client-example", + "gen:python-mcp-example": "npm run build && rimraf ./examples/pet-store-mcp && node ./dist/cli-generate.js -i https://petstore3.swagger.io/api/v3/openapi.json -g python-mcp-server -t server -o ./examples/pet-store-mcp", + "gen:python-pydantic-client-example": "npm run build && rimraf ./examples/python-pydantic-client && node ./dist/cli-generate.js -i https://petstore3.swagger.io/api/v3/openapi.json -g python-pydantic -o ./examples/python-pydantic-client" }, "dependencies": { - "axios": "^1.7.7", + "axios": "^1.16.0", "colors-ext": "^1.0.3", - "jsonpath-plus": "^10.0.0", - "rimraf": "^6.0.1", + "jsonpath-plus": "^10.4.0", + "rimraf": "^6.1.3", "swagger2openapi": "^7.0.8", - "tslib": "^2.7.0", - "yargs": "^17.7.2" + "tslib": "^2.8.1", + "yargs": "^18.0.0" }, "devDependencies": { "@types/jsonpath": "^0.2.4", - "@types/node": "^22.7.5", - "@types/yargs": "^17.0.33", - "cpy-cli": "^5.0.0", - "cross-env": "^7.0.3", - "nodemon": "^3.1.7", - "npm-check-updates": "^17.1.3", - "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "@types/yargs": "^17.0.35", + "cpy-cli": "^7.0.0", + "cross-env": "^10.1.0", + "nodemon": "^3.1.14", + "npm-check-updates": "^22.1.0", + "prettier": "^3.8.3", "react-query": "^3.39.3", - "typescript": "^5.6.3" + "typescript": "^6.0.3" } } diff --git a/src/generators/ClientGeneratorGetter.ts b/src/generators/ClientGeneratorGetter.ts index a80d6cc..2de4e76 100644 --- a/src/generators/ClientGeneratorGetter.ts +++ b/src/generators/ClientGeneratorGetter.ts @@ -2,6 +2,7 @@ import { TypescriptReactQueryClientGenerator } from './client/TypescriptReactQue import { ServerGenerators } from '../models/ServerGenerators'; import { ClientGenerators } from '../models/ClientGenerators'; import { PythonClientGenerator } from './client/PythonClientGenerator'; +import { PythonPydanticClientGenerator } from './client/PythonPydanticClientGenerator'; import { CSharpClientGenerator } from './client/CSharpClientGenerator'; import { TypescriptAxiosClientGenerator } from './client/TypescriptAxiosClientGenerator'; import { GoClientGenerator } from './client/GoClientGenerator'; @@ -23,6 +24,9 @@ export function ClientGeneratorGetter(generator: ClientGenerators | ServerGenera if (generator === ClientGenerators.Python) { return PythonClientGenerator; } + if (generator === ClientGenerators.PythonPydantic) { + return PythonPydanticClientGenerator; + } if (generator === ClientGenerators.Go) { return GoClientGenerator; } diff --git a/src/generators/ServerGeneratorGetter.ts b/src/generators/ServerGeneratorGetter.ts index cdc0511..5dfb255 100644 --- a/src/generators/ServerGeneratorGetter.ts +++ b/src/generators/ServerGeneratorGetter.ts @@ -3,6 +3,7 @@ import { ServerGenerators } from '../models/ServerGenerators'; import { ClientGenerators } from '../models/ClientGenerators'; import { TypescriptNestServerGenerator } from './server/TypescriptNestServerGenerator'; import { CSharpServerGenerator } from './server/CSharpServerGenerator'; +import { PythonMcpServerGenerator } from './server/PythonMcpServerGenerator'; export function ServerGeneratorGetter(generator: ClientGenerators | ServerGenerators) { if (!Object.values(ServerGenerators).find(x => x.toLowerCase() === generator.toLowerCase())) { @@ -21,5 +22,8 @@ export function ServerGeneratorGetter(generator: ClientGenerators | ServerGenera if (generator === ServerGenerators.Go) { return GoServerGenerator; } + if (generator === ServerGenerators.PythonMcpServer) { + return PythonMcpServerGenerator; + } throw new Error('not implemented: ' + generator); } diff --git a/src/generators/client/PythonPydanticClientGenerator.ts b/src/generators/client/PythonPydanticClientGenerator.ts new file mode 100644 index 0000000..e33df14 --- /dev/null +++ b/src/generators/client/PythonPydanticClientGenerator.ts @@ -0,0 +1,301 @@ +import { EditorArrayInput, EditorInput, ApiPath } from '../../models'; +import { getEditorInput2, snakeCase } from '../../helpers'; +import { GeneratorAbstract } from '../GeneratorAbstract'; +import { EditorObjectInput, EditorPrimitiveInput, OpenApiDefinition } from '../../models'; +import { writeFileSync, mkdirSync } from 'fs'; +import { join, basename } from 'path'; + +const tab = ' '.repeat(4); +const systemNames = [`from`, `None`, `True`, `False`, `pass`, `global`, `in`, `except`, `and`, `field`]; + +export class PythonPydanticClientGenerator extends GeneratorAbstract { + modelsFolder = join(this.options.output, 'src', 'client', this.options.modelsFolderName); + controllersFolder = join(this.options.output, 'src', 'client', this.options.controllersFolderName); + protected readonly clientFolder = join(this.options.output, 'src', 'client'); + + generateObject(objectInput: EditorObjectInput): void { + if (!this.shouldGenerateModel(objectInput)) { + return; + } + const fileName = this.getFileName(objectInput); + const modelFile = join(this.modelsFolder, fileName + this.getFileExtension(true)); + + const extendsName = + objectInput.implements.length > 0 + ? `${this.options.modelNamePrefix}${objectInput.implements[0]}${this.options.modelNameSuffix.split('.')[0]}` + : `BaseModel`; + + const classDeclare = `class ${fileName}(${extendsName}):`; + const propertiesContent: string[] = []; + + for (const prop of objectInput.properties) { + const type = this.getPropDesc(prop); + let name = prop.name.replace(/\[i\]/g, '').replace(/-/g, '_'); + if (systemNames.includes(name)) { + name = `_${name}`; + } + const isOptional = prop.nullable || !prop.required; + propertiesContent.push(`${tab}${name}: ${isOptional ? `${type} | None` : type} = None`); + } + + if (propertiesContent.length === 0) { + propertiesContent.push(`${tab}pass`); + } + + const modelFileContent = `${classDeclare}\n${propertiesContent.join('\n')}`; + writeFileSync(modelFile, this.appendModelImports(modelFileContent, extendsName, objectInput.implements[0])); + } + + generateEnum(enumInput: EditorPrimitiveInput, enumVals: { [name: string]: string | number }): void { + if (!this.shouldGenerateModel(enumInput)) { + return; + } + const modelFile = join(this.modelsFolder, this.getFileName(enumInput) + this.getFileExtension(false)); + const classDeclare = `class ${this.getFileName(enumInput)}(str, Enum):`; + const getName = (e: string) => { + if (systemNames.includes(e)) { + e = `_${e}`; + } + e = e.replace(/ /g, '').replace(/-/g, '').replace(/!/g, 'not_'); + return this.getEnumValueName(e); + }; + let modelFileContent = `\n${classDeclare}\n`; + if (Object.keys(enumVals).length === 0) { + modelFileContent += `${tab}pass`; + } else { + modelFileContent += Object.keys(enumVals) + .map(x => `${tab}${getName(x)} = ${typeof enumVals[x] === 'number' ? enumVals[x] : `'${enumVals[x]}'`}`) + .join('\n'); + } + writeFileSync(modelFile, this.appendModelImports(modelFileContent, '', undefined)); + } + + appendModelImports(fileContent: string, extendsName: string, parentClassName?: string): string { + let imports = ``; + + // Import parent class if it's not BaseModel + if (parentClassName && extendsName !== 'BaseModel') { + const parentFile = `${this.options.modelNamePrefix}${parentClassName}${this.options.modelNameSuffix.split('.')[0]}`; + imports += `from ..models.${parentFile} import ${extendsName}\n`; + } + + const modelsRefs = [...(fileContent.matchAll(/models\.(\w+)/g) || [])]; + const seenModels = new Set(); + for (const match of modelsRefs) { + const modelName = match[1]; + if (!seenModels.has(modelName)) { + seenModels.add(modelName); + imports += `from ..models.${modelName} import ${modelName}\n`; + } + } + + const needsBaseModel = fileContent.includes('(BaseModel)') || fileContent.includes('BaseModel'); + const needsEnum = fileContent.includes('(str, Enum)') || fileContent.includes('(Enum)'); + const needsAny = fileContent.includes(': Any') || fileContent.includes('[Any]') || fileContent.includes('Any]'); + const needsDatetime = fileContent.includes(': datetime') || fileContent.includes('[datetime]'); + + if (needsAny) { + imports += `from typing import Any\n`; + } + if (needsDatetime) { + imports += `from datetime import datetime\n`; + } + if (needsBaseModel) { + imports += `from pydantic import BaseModel, ConfigDict\n`; + } + if (needsEnum) { + imports += `from enum import Enum\n`; + } + + return (imports + '\n' + fileContent.replace(/models\./g, '')).trim(); + } + + generateClient(): void { + const outputName = basename(this.options.output).replace(/[^a-z0-9-_]/gi, '-').toLowerCase(); + + writeFileSync(join(this.clientFolder, '__init__.py'), 'from .client import Client\n'); + writeFileSync(join(this.modelsFolder, '__init__.py'), ''); + writeFileSync(join(this.controllersFolder, '__init__.py'), ''); + this.writeClientFile(); + this.writePyprojectToml(outputName); + } + + protected writeClientFile(): void { + const controllerImports = this.parsingResult.controllersNames + .map(x => this.getControllerName(x)) + .map(x => `from .controllers.${x} import ${x}`) + .join('\n'); + + const controllerInits = this.parsingResult.controllersNames + .map(x => this.getControllerName(x)) + .map(x => `${tab}${tab}self.${x} = ${x}(base_url)`) + .join('\n'); + + const mainFileContent = `${controllerImports} +import os + + +class Client: +${tab}def __init__(self, base_url: str | None = None) -> None: +${tab}${tab}base_url = base_url or os.environ.get("BASE_URL", "") +${controllerInits} +`; + writeFileSync(join(this.clientFolder, 'client.py'), mainFileContent); + } + + private writePyprojectToml(outputName: string): void { + const pyprojectContent = `[project] +name = "${outputName}" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27", + "pydantic>=2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/client"] +`; + writeFileSync(join(this.options.output, 'pyproject.toml'), pyprojectContent); + } + + generateController(controller: string, controllerPaths: ApiPath[]): void { + const controllerName = this.getControllerName(controller); + let controllerContent = `class ${controllerName}:\n`; + controllerContent += `${tab}def __init__(self, base_url: str) -> None:\n`; + controllerContent += `${tab}${tab}self._base_url = base_url\n\n`; + controllerContent += this.generateControllerMethodsContent(controller, controllerPaths); + const controllerFile = join(this.controllersFolder, controllerName + this.getFileExtension(false)); + writeFileSync(controllerFile, this.appendControllerImports(controllerContent)); + } + + private appendControllerImports(content: string): string { + let imports = `import httpx\n`; + const modelsRefs = [...(content.matchAll(/models\.(\w+)/g) || [])]; + const seenModels = new Set(); + for (const match of modelsRefs) { + const modelName = match[1]; + if (!seenModels.has(modelName)) { + seenModels.add(modelName); + imports += `from ..models.${modelName} import ${modelName}\n`; + } + } + const needsAny = content.includes(': Any') || content.includes('[Any]'); + if (needsAny) { + imports += `from typing import Any\n`; + } + return (imports + '\n' + content.replace(/models\./g, '')).trim(); + } + + generateControllerMethodContent(controller: string, controllerPath: ApiPath) { + const methodName = this.getMethodName(controllerPath); + const requestType = controllerPath.body.haveBody ? this.getPropDesc(controllerPath.body.schema) : 'None'; + const responseType = this.getPropDesc(controllerPath.response); + + const bodyParam = controllerPath.body.haveBody + ? `body: ${!controllerPath.body.required ? `${requestType} | None` : requestType}, ` + : ''; + + const headers = [...controllerPath.cookieParams, ...controllerPath.headerParams]; + const haveHeaders = headers.length > 0; + const headersParams = haveHeaders + ? headers.map(x => `h_${snakeCase(x.name)}: ${x.required ? 'str' : 'str | None'}`).join(', ') + ', ' + : ''; + + const pathParams = + controllerPath.pathParams.length > 0 + ? controllerPath.pathParams + .map(x => `p_${snakeCase(x.name)}: ${x.required ? this.getPropDesc(x.schema!) : `${this.getPropDesc(x.schema!)} | None`}`) + .join(', ') + ', ' + : ''; + + const queryParams = + controllerPath.queryParams.length > 0 + ? controllerPath.queryParams + .map(x => { + const type = this.getPropDesc(x.schema!); + return x.required + ? `q_${snakeCase(x.name)}: ${type}` + : `q_${snakeCase(x.name)}: ${type} | None = None`; + }) + .join(', ') + ', ' + : ''; + + let urlPath = controllerPath.path; + for (const pathParam of controllerPath.pathParams) { + urlPath = urlPath.replace(`{${pathParam.name}}`, `{p_${snakeCase(pathParam.name)}}`); + } + + const haveQueryParams = controllerPath.queryParams.length > 0; + const headersDict = haveHeaders + ? '{' + headers.map(x => `'${x.name}': h_${snakeCase(x.name)}`).join(', ') + '}' + : 'None'; + + let methodContent = ''; + methodContent += `${tab}async def ${methodName}(self, ${bodyParam}${pathParams}${queryParams}${headersParams}**kwargs) -> ${responseType}:\n`; + methodContent += `${tab}${tab}async with httpx.AsyncClient() as client:\n`; + methodContent += `${tab}${tab}${tab}response = await client.${controllerPath.method.toLowerCase()}(\n`; + methodContent += `${tab}${tab}${tab}${tab}f"{self._base_url}${urlPath}",\n`; + + if (controllerPath.body.haveBody) { + methodContent += `${tab}${tab}${tab}${tab}json=body.model_dump() if hasattr(body, 'model_dump') else body,\n`; + } + if (haveQueryParams) { + const paramsDict = '{' + controllerPath.queryParams.map(x => `"${x.name}": q_${snakeCase(x.name)}`).join(', ') + '}'; + methodContent += `${tab}${tab}${tab}${tab}params={k: v for k, v in ${paramsDict}.items() if v is not None},\n`; + } + if (haveHeaders) { + methodContent += `${tab}${tab}${tab}${tab}headers=${headersDict},\n`; + } + methodContent += `${tab}${tab}${tab}${tab}**kwargs,\n`; + methodContent += `${tab}${tab}${tab})\n`; + methodContent += `${tab}${tab}${tab}response.raise_for_status()\n`; + methodContent += `${tab}${tab}${tab}return response.json()\n`; + + return { methodContent, methodName }; + } + + getPropDesc(obj: EditorInput | OpenApiDefinition): string { + const editorInput = (obj as EditorInput)?.editorType + ? (obj as EditorInput) + : getEditorInput2(this.swagger, obj as OpenApiDefinition); + const fileName = this.getFileName(editorInput); + if (editorInput.editorType === 'EditorPrimitiveInput') { + const primitiveInput = editorInput as EditorPrimitiveInput; + switch (primitiveInput.type) { + case 'number': + return primitiveInput.openApiDefinition?.type === 'integer' ? 'int' : 'float'; + case 'string': + return 'str'; + case 'boolean': + return 'bool'; + case 'date': + return 'datetime'; + case 'enum': + return fileName ? `models.${fileName}` : 'str'; + } + } + if (editorInput.editorType === 'EditorArrayInput') { + const arrayInput = editorInput as EditorArrayInput; + return `list[${this.getPropDesc(arrayInput.itemInput)}]`; + } + if (editorInput.editorType === 'EditorObjectInput') { + const objectInput = editorInput as EditorObjectInput; + if (!objectInput.isDictionary) { + return fileName ? `models.${fileName}` : 'dict'; + } + return `dict[${objectInput.dictionaryKeyInput ? this.getPropDesc(objectInput.dictionaryKeyInput) : 'str'}, ${ + objectInput.dictionaryInput ? this.getPropDesc(objectInput.dictionaryInput) : 'Any' + }]`; + } + return 'Any'; + } + + getFileExtension(_isModel: boolean): string { + return '.py'; + } +} diff --git a/src/generators/server/PythonMcpServerGenerator.ts b/src/generators/server/PythonMcpServerGenerator.ts new file mode 100644 index 0000000..0ab50ca --- /dev/null +++ b/src/generators/server/PythonMcpServerGenerator.ts @@ -0,0 +1,290 @@ +import { ApiPath } from '../../models'; +import { snakeCase } from '../../helpers'; +import { PythonPydanticClientGenerator } from '../client/PythonPydanticClientGenerator'; +import { writeFileSync, mkdirSync } from 'fs'; +import { join, basename } from 'path'; + +const tab = ' '.repeat(4); + +export class PythonMcpServerGenerator extends PythonPydanticClientGenerator { + // Inherits modelsFolder = src/client/models, controllersFolder = src/client/controllers, + // clientFolder = src/client, and all model/controller generation from PythonPydanticClientGenerator. + + // Override generateClient to write client files without a standalone pyproject.toml. + generateClient(): void { + writeFileSync(join(this.clientFolder, '__init__.py'), 'from .client import Client\n'); + writeFileSync(join(this.modelsFolder, '__init__.py'), ''); + writeFileSync(join(this.controllersFolder, '__init__.py'), ''); + this.writeClientFile(); + } + + async generate(): Promise { + // super.generate() → PythonPydanticClientGenerator → GeneratorAbstract: + // deletes output, creates src/client/{models,controllers}, generates all models & controllers, + // then calls this.generateClient() (overridden above — no standalone pyproject.toml). + await super.generate(); + + const serverFolder = join(this.options.output, 'src', 'server'); + const toolsFolder = join(serverFolder, 'tools'); + mkdirSync(toolsFolder, { recursive: true }); + + this.writeMcpPyprojectToml(); + this.writeReadme(); + this.writeMcpServerFile(); + + // Reset methodsNames so tool files get the same method names as the controllers. + this.methodsNames = {}; + this.writeToolFiles(toolsFolder); + + writeFileSync(join(this.options.output, 'src', '__init__.py'), ''); + writeFileSync(join(serverFolder, '__init__.py'), ''); + writeFileSync(join(toolsFolder, '__init__.py'), ''); + } + + private writeMcpPyprojectToml(): void { + const outputName = basename(this.options.output).replace(/[^a-z0-9-_]/gi, '-').toLowerCase(); + const content = `[project] +name = "${outputName}" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.0", + "httpx>=0.27", + "pydantic>=2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/client", "src/server"] + +[project.scripts] +${outputName} = "mcp_server:main" +`; + writeFileSync(join(this.options.output, 'pyproject.toml'), content); + } + + private writeReadme(): void { + const outputName = basename(this.options.output); + const content = `# ${outputName} + +MCP server generated by [openapi-toolkit](https://github.com/barnuri/openapi-toolkit). + +## Install + +\`\`\`bash +uv sync +\`\`\` + +## Run + +\`\`\`bash +# stdio (default — for Claude Desktop and most MCP clients) +BASE_URL=https://api.example.com uv run python src/mcp_server.py + +# SSE +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport sse --host 0.0.0.0 --port 8000 + +# Streamable HTTP +BASE_URL=https://api.example.com uv run python src/mcp_server.py --transport streamable-http --host 0.0.0.0 --port 8000 --path /mcp +\`\`\` + +## Environment variables + +| Variable | Description | +|---|---| +| \`BASE_URL\` | Base URL of the target API (required) | +| \`TOOL_FILTER_ROUTES\` | Comma-separated controller names to load (e.g. \`pet,store\`). Loads all if unset. | +| \`TOOL_FILTER_METHODS\` | Comma-separated tool function names to register (e.g. \`getPetById,addPet\`). Registers all if unset. | + +## CLI options + +\`\`\` +--transport stdio | sse | streamable-http (default: stdio) +--host host for HTTP transports (default: 0.0.0.0) +--port port for HTTP transports (default: 8000) +--path path for streamable-http (default: /mcp) +\`\`\` + +## Project layout + +\`\`\` +src/ + mcp_server.py # entry point — tool registration and transport CLI + client/ # async httpx + Pydantic v2 client (generated inline) + server/tools/ # one file per API controller +\`\`\` +`; + writeFileSync(join(this.options.output, 'README.md'), content); + } + + private writeMcpServerFile(): void { + const controllerNames = this.parsingResult.controllersNames.map(x => snakeCase(this.getControllerName(x))); + const allToolsList = controllerNames.map(x => `"${x}"`).join(', '); + + const content = `import importlib +import os +import sys +from functools import lru_cache +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from mcp.server.fastmcp import FastMCP +from client import Client + +mcp = FastMCP("${basename(this.options.output)}") + + +@lru_cache(maxsize=1) +def get_client() -> Client: + return Client(os.environ.get("BASE_URL", "")) + + +_route_filter = [r.strip() for r in os.environ.get("TOOL_FILTER_ROUTES", "").split(",") if r.strip()] +_method_filter = [m.strip() for m in os.environ.get("TOOL_FILTER_METHODS", "").split(",") if m.strip()] + +ALL_TOOLS: list[str] = [${allToolsList}] + +for _module_name in ALL_TOOLS: + if not _route_filter or _module_name in _route_filter: + _module = importlib.import_module(f"server.tools.{_module_name}") + for _tool in _module.TOOLS: + if not _method_filter or _tool.__name__ in _method_filter: + mcp.tool()(_tool) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="${basename(this.options.output)} MCP server") + parser.add_argument("--transport", default="stdio", choices=["stdio", "sse", "streamable-http"], help="Transport type (default: stdio)") + parser.add_argument("--host", default="0.0.0.0", help="Host for HTTP transports (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8000, help="Port for HTTP transports (default: 8000)") + parser.add_argument("--path", default="/mcp", help="Path for streamable-http transport (default: /mcp)") + args = parser.parse_args() + + run_kwargs: dict = {"transport": args.transport} + if args.transport != "stdio": + run_kwargs["host"] = args.host + run_kwargs["port"] = args.port + if args.transport == "streamable-http": + run_kwargs["path"] = args.path + + mcp.run(**run_kwargs) + + +if __name__ == "__main__": + main() +`; + writeFileSync(join(this.options.output, 'src', 'mcp_server.py'), content); + } + + private writeToolFiles(toolsFolder: string): void { + for (const controllerName of this.parsingResult.controllersNames) { + const controllerPaths = this.parsingResult.apiPaths.filter( + x => x.controller.toLowerCase() === controllerName.toLowerCase(), + ); + this.writeToolFile(controllerName, controllerPaths, toolsFolder); + } + } + + private writeToolFile(controller: string, controllerPaths: ApiPath[], toolsFolder: string): void { + const moduleName = snakeCase(this.getControllerName(controller)); + const clientControllerAttr = this.getControllerName(controller); + let imports = `from mcp_server import get_client\n`; + const funcDefs: string[] = []; + const toolNames: string[] = []; + + for (const controllerPath of controllerPaths) { + const { funcDef, methodName } = this.generateToolMethodContent(controller, controllerPath, clientControllerAttr); + funcDefs.push(funcDef); + toolNames.push(methodName); + } + + const rawContent = funcDefs.join('\n\n'); + + const modelsRefs = [...(rawContent.matchAll(/models\.(\w+)/g) || [])]; + const seenModels = new Set(); + for (const match of modelsRefs) { + const modelName = match[1]; + if (!seenModels.has(modelName)) { + seenModels.add(modelName); + imports += `from client.models.${modelName} import ${modelName}\n`; + } + } + + const allContent = rawContent.replace(/models\./g, ''); + const needsAny = allContent.includes(': Any') || allContent.includes('[Any]'); + if (needsAny) { + imports += `from typing import Any\n`; + } + + const toolsList = toolNames.join(', '); + const fileContent = `${imports}\n${allContent}\n\nTOOLS = [${toolsList}]\n`; + writeFileSync(join(toolsFolder, `${moduleName}.py`), fileContent); + } + + private generateToolMethodContent( + _controller: string, + controllerPath: ApiPath, + clientControllerAttr: string, + ): { funcDef: string; methodName: string } { + const methodName = this.getMethodName(controllerPath); + const requestType = controllerPath.body.haveBody ? this.getPropDesc(controllerPath.body.schema) : 'None'; + + const bodyParam = controllerPath.body.haveBody + ? `body: ${!controllerPath.body.required ? `${requestType} | None` : requestType}, ` + : ''; + + const headers = [...controllerPath.cookieParams, ...controllerPath.headerParams]; + const haveHeaders = headers.length > 0; + const headersParams = haveHeaders + ? headers.map(x => `h_${snakeCase(x.name)}: ${x.required ? 'str' : 'str | None'}`).join(', ') + ', ' + : ''; + + const pathParams = + controllerPath.pathParams.length > 0 + ? controllerPath.pathParams + .map(x => `p_${snakeCase(x.name)}: ${x.required ? this.getPropDesc(x.schema!) : `${this.getPropDesc(x.schema!)} | None`}`) + .join(', ') + ', ' + : ''; + + const queryParams = + controllerPath.queryParams.length > 0 + ? controllerPath.queryParams + .map(x => { + const type = this.getPropDesc(x.schema!); + return x.required + ? `q_${snakeCase(x.name)}: ${type}` + : `q_${snakeCase(x.name)}: ${type} | None = None`; + }) + .join(', ') + ', ' + : ''; + + const allArgs: string[] = []; + if (controllerPath.body.haveBody) { + allArgs.push('body=body'); + } + for (const p of controllerPath.pathParams) { + allArgs.push(`p_${snakeCase(p.name)}=p_${snakeCase(p.name)}`); + } + for (const q of controllerPath.queryParams) { + allArgs.push(`q_${snakeCase(q.name)}=q_${snakeCase(q.name)}`); + } + for (const h of headers) { + allArgs.push(`h_${snakeCase(h.name)}=h_${snakeCase(h.name)}`); + } + + let funcDef = ''; + funcDef += `async def ${methodName}(${bodyParam}${pathParams}${queryParams}${headersParams}) -> dict:\n`; + funcDef += `${tab}"""${controllerPath.method.toUpperCase()} ${controllerPath.path}"""\n`; + funcDef += `${tab}client = get_client()\n`; + funcDef += `${tab}return await client.${clientControllerAttr}.${methodName}(${allArgs.join(', ')})\n`; + + return { funcDef, methodName }; + } + +} diff --git a/src/models/ClientGenerators.ts b/src/models/ClientGenerators.ts index c320f8d..35524e2 100644 --- a/src/models/ClientGenerators.ts +++ b/src/models/ClientGenerators.ts @@ -3,5 +3,6 @@ export enum ClientGenerators { TypescriptReactQuery = 'typescript-react-query', CSharp = 'c#', Python = 'python', + PythonPydantic = 'python-pydantic', Go = 'go', } diff --git a/src/models/ServerGenerators.ts b/src/models/ServerGenerators.ts index ac2ed03..682e4da 100644 --- a/src/models/ServerGenerators.ts +++ b/src/models/ServerGenerators.ts @@ -2,4 +2,5 @@ export enum ServerGenerators { TypescriptNest = 'typescript-nest', CSharp = 'c#', Go = 'go', + PythonMcpServer = 'python-mcp-server', } diff --git a/tsconfig-test.json b/tsconfig-test.json index a101a0a..63d3eaa 100644 --- a/tsconfig-test.json +++ b/tsconfig-test.json @@ -1,9 +1,10 @@ { "compilerOptions": { "module": "commonjs", - "target": "es5", + "target": "ES2020", "jsx": "preserve", "moduleResolution": "node", + "ignoreDeprecations": "6.0", "emitDecoratorMetadata": true, "experimentalDecorators": true, "sourceMap": true, @@ -16,7 +17,6 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "importHelpers": true, - "downlevelIteration": true, "strict": true, "alwaysStrict": true, "newLine": "LF", @@ -28,7 +28,7 @@ "strictBindCallApply": true, "strictNullChecks": true, "skipLibCheck": true, - "lib": ["es2015", "dom"], + "lib": ["ES2020", "dom"], "rootDir": "test" }, "include": ["test-typescript-axios", "test-typescript-models", "test-typescript-react-query"], diff --git a/tsconfig.json b/tsconfig.json index 49c9e4d..5e29360 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,11 @@ { "compilerOptions": { "module": "commonjs", - "target": "ES2017", + "target": "ES2020", + "lib": ["ES2020"], + "types": ["node"], "moduleResolution": "node", + "ignoreDeprecations": "6.0", "emitDecoratorMetadata": true, "experimentalDecorators": true, "sourceMap": true,