spec2mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ out/
9
+ .DS_Store
spec2mcp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 api2mcp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.5
2
+ Name: spec2mcp
3
+ Version: 0.1.0
4
+ Summary: Turn any OpenAPI spec into a working MCP server. One command.
5
+ Project-URL: Homepage, https://github.com/azamoviich/api2mcp
6
+ Project-URL: Repository, https://github.com/azamoviich/api2mcp
7
+ Author: api2mcp contributors
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,anthropic,claude,codegen,llm,mcp,openapi,tools
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Code Generators
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: jinja2>=3.1
17
+ Requires-Dist: pyyaml>=6.0
18
+ Requires-Dist: requests>=2.28
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == 'dev'
21
+ Provides-Extra: serve
22
+ Requires-Dist: mcp>=1.0; extra == 'serve'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # api2mcp
26
+
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
28
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](pyproject.toml)
29
+
30
+ **Turn any OpenAPI spec into a working [MCP](https://modelcontextprotocol.io) server. One command.**
31
+
32
+ ```bash
33
+ api2mcp https://petstore3.swagger.io/api/v3/openapi.json
34
+ ```
35
+
36
+ That's it. You now have a runnable MCP server exposing every endpoint in that API as a tool an LLM agent can call — typed arguments, docstrings, auth wiring, all generated.
37
+
38
+ ---
39
+
40
+ ## The problem
41
+
42
+ Want Claude (or any MCP-compatible agent) to use Stripe, GitHub, your internal REST API, whatever? Right now that means hand-writing an MCP server: read the docs, define a tool per endpoint, map params, wire up auth, keep it in sync when the API changes.
43
+
44
+ Almost every API already publishes an OpenAPI/Swagger spec describing exactly that shape. `api2mcp` reads it and generates the server for you.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install spec2mcp
50
+ ```
51
+
52
+ (The PyPI package is named `spec2mcp` — `api2mcp` was already taken. The CLI command and import name are still `api2mcp`.)
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ api2mcp <spec-url-or-file> [-o output-dir]
58
+ ```
59
+
60
+ Works with a spec URL, a local `.json` file, or a local `.yaml`/`.yml` file.
61
+
62
+ ### Example
63
+
64
+ ```bash
65
+ $ api2mcp https://petstore3.swagger.io/api/v3/openapi.json -o ./petstore-mcp
66
+ Fetching spec from https://petstore3.swagger.io/api/v3/openapi.json ...
67
+ Generated 19 tools for 'Swagger Petstore - OpenAPI 3.0'
68
+ -> petstore-mcp/server.py
69
+ -> petstore-mcp/README.md
70
+
71
+ Run it:
72
+ cd petstore-mcp && pip install "mcp[cli]" requests && python server.py
73
+ ```
74
+
75
+ Run the generated server, then point any MCP client at it — Claude Desktop, Claude Code, or your own agent — and every endpoint (`findPetsByStatus`, `addPet`, `deletePet`, …) is now a callable tool.
76
+
77
+ ### Point Claude Desktop / Claude Code at it
78
+
79
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "petstore": {
85
+ "command": "python",
86
+ "args": ["/absolute/path/to/petstore-mcp/server.py"],
87
+ "env": {
88
+ "API_BASE_URL": "https://petstore3.swagger.io/api/v3",
89
+ "API_KEY": "your-key-if-needed"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Auth
97
+
98
+ Set env vars before running the generated server:
99
+
100
+ - `API_BASE_URL` — overrides the base URL detected from the spec
101
+ - `API_KEY` — sent as `Authorization: Bearer <API_KEY>` on every request
102
+
103
+ ### Use it as a library instead of the CLI
104
+
105
+ ```python
106
+ from api2mcp import parse_spec, write_server
107
+
108
+ spec = parse_spec("https://petstore3.swagger.io/api/v3/openapi.json")
109
+ write_server(spec, "./out")
110
+ ```
111
+
112
+ ## What gets generated
113
+
114
+ For every operation in the spec, one `@mcp.tool()`-decorated function:
115
+
116
+ ```python
117
+ @mcp.tool()
118
+ def findpetsbystatus(status: str = "") -> dict:
119
+ """Finds Pets by status."""
120
+ ...
121
+ resp = requests.request("GET", url, params=params, json=json_body, headers=_headers(), timeout=30)
122
+ resp.raise_for_status()
123
+ return resp.json()
124
+ ```
125
+
126
+ - Path, query, and JSON body params become typed Python arguments (required params ordered before optional ones, so it's always valid Python)
127
+ - The `summary`/`description` from the spec becomes the tool's docstring — that's what the LLM sees when deciding whether to call it
128
+ - A `README.md` listing every generated tool ships alongside `server.py`
129
+
130
+ The output is plain, readable code — not a black box. Generate it, read it, edit it by hand if you need something custom.
131
+
132
+ ## How it works
133
+
134
+ 1. **Parse** (`api2mcp/parser.py`) — loads the spec (JSON or YAML, URL or file), walks `paths`, flattens each operation's parameters and request body into a simple typed `Operation` model.
135
+ 2. **Generate** (`api2mcp/generator.py` + `templates/server.py.j2`) — renders a Jinja2 template into a single-file MCP server using the official `mcp` Python SDK.
136
+ 3. **Run** — the generated server is a normal Python script; `mcp.run()` speaks the MCP protocol over stdio.
137
+
138
+ No LLM calls involved in generation — it's pure codegen from the spec's structure, so it's fast, free, and deterministic.
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ git clone https://github.com/azamoviich/api2mcp
144
+ cd api2mcp
145
+ python -m venv .venv && source .venv/bin/activate
146
+ pip install -e ".[dev,serve]"
147
+ pytest
148
+ ```
149
+
150
+ ## Limitations (v1)
151
+
152
+ - No OAuth2 flows — only static bearer token auth via `API_KEY`
153
+ - `$ref` resolution for request bodies is shallow (one level)
154
+ - No pagination helpers — generated tools return raw responses as-is
155
+
156
+ Contributions welcome for any of the above.
157
+
158
+ ## License
159
+
160
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,136 @@
1
+ # api2mcp
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](pyproject.toml)
5
+
6
+ **Turn any OpenAPI spec into a working [MCP](https://modelcontextprotocol.io) server. One command.**
7
+
8
+ ```bash
9
+ api2mcp https://petstore3.swagger.io/api/v3/openapi.json
10
+ ```
11
+
12
+ That's it. You now have a runnable MCP server exposing every endpoint in that API as a tool an LLM agent can call — typed arguments, docstrings, auth wiring, all generated.
13
+
14
+ ---
15
+
16
+ ## The problem
17
+
18
+ Want Claude (or any MCP-compatible agent) to use Stripe, GitHub, your internal REST API, whatever? Right now that means hand-writing an MCP server: read the docs, define a tool per endpoint, map params, wire up auth, keep it in sync when the API changes.
19
+
20
+ Almost every API already publishes an OpenAPI/Swagger spec describing exactly that shape. `api2mcp` reads it and generates the server for you.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install spec2mcp
26
+ ```
27
+
28
+ (The PyPI package is named `spec2mcp` — `api2mcp` was already taken. The CLI command and import name are still `api2mcp`.)
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ api2mcp <spec-url-or-file> [-o output-dir]
34
+ ```
35
+
36
+ Works with a spec URL, a local `.json` file, or a local `.yaml`/`.yml` file.
37
+
38
+ ### Example
39
+
40
+ ```bash
41
+ $ api2mcp https://petstore3.swagger.io/api/v3/openapi.json -o ./petstore-mcp
42
+ Fetching spec from https://petstore3.swagger.io/api/v3/openapi.json ...
43
+ Generated 19 tools for 'Swagger Petstore - OpenAPI 3.0'
44
+ -> petstore-mcp/server.py
45
+ -> petstore-mcp/README.md
46
+
47
+ Run it:
48
+ cd petstore-mcp && pip install "mcp[cli]" requests && python server.py
49
+ ```
50
+
51
+ Run the generated server, then point any MCP client at it — Claude Desktop, Claude Code, or your own agent — and every endpoint (`findPetsByStatus`, `addPet`, `deletePet`, …) is now a callable tool.
52
+
53
+ ### Point Claude Desktop / Claude Code at it
54
+
55
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "petstore": {
61
+ "command": "python",
62
+ "args": ["/absolute/path/to/petstore-mcp/server.py"],
63
+ "env": {
64
+ "API_BASE_URL": "https://petstore3.swagger.io/api/v3",
65
+ "API_KEY": "your-key-if-needed"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### Auth
73
+
74
+ Set env vars before running the generated server:
75
+
76
+ - `API_BASE_URL` — overrides the base URL detected from the spec
77
+ - `API_KEY` — sent as `Authorization: Bearer <API_KEY>` on every request
78
+
79
+ ### Use it as a library instead of the CLI
80
+
81
+ ```python
82
+ from api2mcp import parse_spec, write_server
83
+
84
+ spec = parse_spec("https://petstore3.swagger.io/api/v3/openapi.json")
85
+ write_server(spec, "./out")
86
+ ```
87
+
88
+ ## What gets generated
89
+
90
+ For every operation in the spec, one `@mcp.tool()`-decorated function:
91
+
92
+ ```python
93
+ @mcp.tool()
94
+ def findpetsbystatus(status: str = "") -> dict:
95
+ """Finds Pets by status."""
96
+ ...
97
+ resp = requests.request("GET", url, params=params, json=json_body, headers=_headers(), timeout=30)
98
+ resp.raise_for_status()
99
+ return resp.json()
100
+ ```
101
+
102
+ - Path, query, and JSON body params become typed Python arguments (required params ordered before optional ones, so it's always valid Python)
103
+ - The `summary`/`description` from the spec becomes the tool's docstring — that's what the LLM sees when deciding whether to call it
104
+ - A `README.md` listing every generated tool ships alongside `server.py`
105
+
106
+ The output is plain, readable code — not a black box. Generate it, read it, edit it by hand if you need something custom.
107
+
108
+ ## How it works
109
+
110
+ 1. **Parse** (`api2mcp/parser.py`) — loads the spec (JSON or YAML, URL or file), walks `paths`, flattens each operation's parameters and request body into a simple typed `Operation` model.
111
+ 2. **Generate** (`api2mcp/generator.py` + `templates/server.py.j2`) — renders a Jinja2 template into a single-file MCP server using the official `mcp` Python SDK.
112
+ 3. **Run** — the generated server is a normal Python script; `mcp.run()` speaks the MCP protocol over stdio.
113
+
114
+ No LLM calls involved in generation — it's pure codegen from the spec's structure, so it's fast, free, and deterministic.
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ git clone https://github.com/azamoviich/api2mcp
120
+ cd api2mcp
121
+ python -m venv .venv && source .venv/bin/activate
122
+ pip install -e ".[dev,serve]"
123
+ pytest
124
+ ```
125
+
126
+ ## Limitations (v1)
127
+
128
+ - No OAuth2 flows — only static bearer token auth via `API_KEY`
129
+ - `$ref` resolution for request bodies is shallow (one level)
130
+ - No pagination helpers — generated tools return raw responses as-is
131
+
132
+ Contributions welcome for any of the above.
133
+
134
+ ## License
135
+
136
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "spec2mcp"
7
+ version = "0.1.0"
8
+ description = "Turn any OpenAPI spec into a working MCP server. One command."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "api2mcp contributors" }]
13
+ keywords = ["mcp", "openapi", "llm", "agent", "tools", "codegen", "anthropic", "claude"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Code Generators",
19
+ ]
20
+ dependencies = [
21
+ "requests>=2.28",
22
+ "PyYAML>=6.0",
23
+ "Jinja2>=3.1",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=7.0"]
28
+ serve = ["mcp>=1.0"] # works with both 1.x (FastMCP) and 2.x (MCPServer) APIs
29
+
30
+ [project.scripts]
31
+ api2mcp = "api2mcp.cli:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/azamoviich/api2mcp"
35
+ Repository = "https://github.com/azamoviich/api2mcp"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/api2mcp"]
@@ -0,0 +1,5 @@
1
+ from .parser import parse_spec, ApiSpec, Operation, Param
2
+ from .generator import render_server, write_server
3
+
4
+ __all__ = ["parse_spec", "ApiSpec", "Operation", "Param", "render_server", "write_server"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .generator import write_server
7
+ from .parser import parse_spec
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(
12
+ prog="api2mcp",
13
+ description="Turn any OpenAPI spec into a working MCP server.",
14
+ )
15
+ parser.add_argument("source", help="OpenAPI spec URL or local file path (json or yaml)")
16
+ parser.add_argument("-o", "--out", default="./mcp_server", help="Output directory (default: ./mcp_server)")
17
+ args = parser.parse_args()
18
+
19
+ print(f"Fetching spec from {args.source} ...")
20
+ try:
21
+ spec = parse_spec(args.source)
22
+ except Exception as exc: # noqa: BLE001
23
+ print(f"error: failed to parse spec: {exc}", file=sys.stderr)
24
+ sys.exit(1)
25
+
26
+ if not spec.operations:
27
+ print("error: no operations found in spec", file=sys.stderr)
28
+ sys.exit(1)
29
+
30
+ out_file = write_server(spec, args.out)
31
+ print(f"Generated {len(spec.operations)} tools for '{spec.title}'")
32
+ print(f"-> {out_file}")
33
+ print(f"-> {out_file.parent / 'README.md'}")
34
+ print("\nRun it:")
35
+ print(f' cd {args.out} && pip install "mcp[cli]" requests && python server.py')
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
6
+
7
+ from .parser import ApiSpec
8
+
9
+ TEMPLATE_DIR = Path(__file__).parent / "templates"
10
+
11
+ _env = Environment(
12
+ loader=FileSystemLoader(str(TEMPLATE_DIR)),
13
+ autoescape=select_autoescape(disabled_extensions=("j2",)),
14
+ trim_blocks=True,
15
+ lstrip_blocks=True,
16
+ )
17
+
18
+
19
+ def render_server(spec: ApiSpec) -> str:
20
+ template = _env.get_template("server.py.j2")
21
+ return template.render(spec=spec)
22
+
23
+
24
+ def write_server(spec: ApiSpec, out_dir: str) -> Path:
25
+ out_path = Path(out_dir)
26
+ out_path.mkdir(parents=True, exist_ok=True)
27
+
28
+ code = render_server(spec)
29
+ server_file = out_path / "server.py"
30
+ server_file.write_text(code)
31
+
32
+ readme = out_path / "README.md"
33
+ tool_lines = "\n".join(f"- `{op.func_name}` — {op.summary or (op.method.upper() + ' ' + op.path)}" for op in spec.operations)
34
+ readme.write_text(
35
+ f"""# {spec.title} — MCP Server
36
+
37
+ Generated by [api2mcp](https://github.com/plagueson/api2mcp) from an OpenAPI spec.
38
+
39
+ ## Setup
40
+
41
+ ```bash
42
+ pip install "mcp[cli]" requests
43
+ export API_BASE_URL="{spec.base_url}"
44
+ export API_KEY="your-key-if-needed"
45
+ python server.py
46
+ ```
47
+
48
+ ## Tools ({len(spec.operations)})
49
+
50
+ {tool_lines}
51
+ """
52
+ )
53
+ return server_file
@@ -0,0 +1,149 @@
1
+ """Loads an OpenAPI spec and flattens it into a list of Operation objects."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ import requests
11
+ import yaml
12
+
13
+ PY_TYPE_MAP = {
14
+ "string": "str",
15
+ "integer": "int",
16
+ "number": "float",
17
+ "boolean": "bool",
18
+ "array": "list",
19
+ "object": "dict",
20
+ }
21
+
22
+
23
+ @dataclass
24
+ class Param:
25
+ name: str
26
+ location: str # "path" | "query" | "header" | "body"
27
+ py_type: str = "str"
28
+ required: bool = False
29
+ description: str = ""
30
+
31
+
32
+ @dataclass
33
+ class Operation:
34
+ op_id: str
35
+ method: str
36
+ path: str
37
+ summary: str = ""
38
+ params: list[Param] = field(default_factory=list)
39
+
40
+ @property
41
+ def func_name(self) -> str:
42
+ name = re.sub(r"[^a-zA-Z0-9]+", "_", self.op_id).strip("_").lower()
43
+ return name or f"{self.method}_{self.path}".lower()
44
+
45
+
46
+ @dataclass
47
+ class ApiSpec:
48
+ title: str
49
+ base_url: str
50
+ operations: list[Operation]
51
+
52
+
53
+ def _load_raw(source: str) -> dict:
54
+ parsed = urlparse(source)
55
+ if parsed.scheme in ("http", "https"):
56
+ resp = requests.get(source, timeout=15)
57
+ resp.raise_for_status()
58
+ text = resp.text
59
+ else:
60
+ text = Path(source).read_text()
61
+
62
+ text_stripped = text.lstrip()
63
+ if text_stripped.startswith("{"):
64
+ return json.loads(text)
65
+ return yaml.safe_load(text)
66
+
67
+
68
+ def _resolve_type(schema: dict | None) -> str:
69
+ if not schema:
70
+ return "str"
71
+ return PY_TYPE_MAP.get(schema.get("type"), "str")
72
+
73
+
74
+ def _base_url(raw: dict, source: str) -> str:
75
+ parsed_source = urlparse(source)
76
+ source_origin = (
77
+ f"{parsed_source.scheme}://{parsed_source.netloc}"
78
+ if parsed_source.scheme in ("http", "https")
79
+ else None
80
+ )
81
+
82
+ servers = raw.get("servers") or []
83
+ if servers and servers[0].get("url"):
84
+ url = servers[0]["url"]
85
+ if url.startswith("http"):
86
+ return url
87
+ if url.startswith("/") and source_origin:
88
+ return source_origin.rstrip("/") + url
89
+
90
+ return source_origin or "http://localhost:8000"
91
+
92
+
93
+ def parse_spec(source: str) -> ApiSpec:
94
+ raw = _load_raw(source)
95
+ title = (raw.get("info") or {}).get("title", "API")
96
+ base_url = _base_url(raw, source)
97
+
98
+ operations: list[Operation] = []
99
+ paths = raw.get("paths") or {}
100
+ for path, methods in paths.items():
101
+ for method, op in methods.items():
102
+ if method.lower() not in ("get", "post", "put", "patch", "delete"):
103
+ continue
104
+ op_id = op.get("operationId") or f"{method}_{path}"
105
+ params: list[Param] = []
106
+
107
+ for p in op.get("parameters", []):
108
+ schema = p.get("schema") or {}
109
+ params.append(
110
+ Param(
111
+ name=p["name"],
112
+ location=p.get("in", "query"),
113
+ py_type=_resolve_type(schema),
114
+ required=p.get("required", False),
115
+ description=p.get("description", ""),
116
+ )
117
+ )
118
+
119
+ body = op.get("requestBody")
120
+ if body:
121
+ content = body.get("content", {})
122
+ json_schema = (content.get("application/json") or {}).get("schema", {})
123
+ props = json_schema.get("properties", {})
124
+ required_fields = set(json_schema.get("required", []))
125
+ if props:
126
+ for pname, pschema in props.items():
127
+ params.append(
128
+ Param(
129
+ name=pname,
130
+ location="body",
131
+ py_type=_resolve_type(pschema),
132
+ required=pname in required_fields,
133
+ description=pschema.get("description", ""),
134
+ )
135
+ )
136
+ else:
137
+ params.append(Param(name="body", location="body", py_type="dict", required=True))
138
+
139
+ operations.append(
140
+ Operation(
141
+ op_id=op_id,
142
+ method=method.lower(),
143
+ path=path,
144
+ summary=op.get("summary", "") or op.get("description", ""),
145
+ params=params,
146
+ )
147
+ )
148
+
149
+ return ApiSpec(title=title, base_url=base_url, operations=operations)
@@ -0,0 +1,79 @@
1
+ """
2
+ Auto-generated MCP server for {{ spec.title }}.
3
+ Generated by api2mcp — https://github.com/azamoviich/api2mcp
4
+
5
+ Run:
6
+ pip install "mcp[cli]" requests
7
+ python server.py
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import requests
13
+
14
+ try:
15
+ from mcp.server.fastmcp import FastMCP # mcp < 2.0
16
+ except ImportError:
17
+ from mcp.server.mcpserver import MCPServer as FastMCP # mcp >= 2.0
18
+
19
+ BASE_URL = os.environ.get("API_BASE_URL", "{{ spec.base_url }}")
20
+ API_KEY = os.environ.get("API_KEY", "")
21
+
22
+ mcp = FastMCP("{{ spec.title }}")
23
+
24
+
25
+ def _headers() -> dict:
26
+ headers = {"Accept": "application/json"}
27
+ if API_KEY:
28
+ headers["Authorization"] = f"Bearer {API_KEY}"
29
+ return headers
30
+
31
+ {% for op in spec.operations %}
32
+
33
+ @mcp.tool()
34
+ def {{ op.func_name }}({% set ordered = (op.params | selectattr("required") | list) + (op.params | rejectattr("required") | list) %}{% for p in ordered %}{{ p.name }}: {{ p.py_type }}{% if not p.required %} = {% if p.py_type == 'str' %}""{% elif p.py_type == 'bool' %}False{% elif p.py_type in ('int', 'float') %}0{% else %}None{% endif %}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}) -> dict:
35
+ """{{ op.summary or (op.method.upper() ~ " " ~ op.path) }}"""
36
+ path = "{{ op.path }}"
37
+ {% for p in op.params if p.location == "path" %}
38
+ path = path.replace("{{ '{' ~ p.name ~ '}' }}", str({{ p.name }}))
39
+ {% endfor %}
40
+ url = BASE_URL.rstrip("/") + path
41
+ {% set query_params = op.params | selectattr("location", "equalto", "query") | list %}
42
+ {% set body_params = op.params | selectattr("location", "equalto", "body") | list %}
43
+ {% if query_params %}
44
+ params = {
45
+ {% for p in query_params %}
46
+ "{{ p.name }}": {{ p.name }},
47
+ {% endfor %}
48
+ }
49
+ params = {k: v for k, v in params.items() if v not in (None, "")}
50
+ {% else %}
51
+ params = None
52
+ {% endif %}
53
+ {% if body_params %}
54
+ json_body = {
55
+ {% for p in body_params %}
56
+ "{{ p.name }}": {{ p.name }},
57
+ {% endfor %}
58
+ }
59
+ {% else %}
60
+ json_body = None
61
+ {% endif %}
62
+ resp = requests.request(
63
+ "{{ op.method.upper() }}",
64
+ url,
65
+ params=params,
66
+ json=json_body,
67
+ headers=_headers(),
68
+ timeout=30,
69
+ )
70
+ resp.raise_for_status()
71
+ try:
72
+ return resp.json()
73
+ except ValueError:
74
+ return {"status_code": resp.status_code, "text": resp.text}
75
+ {% endfor %}
76
+
77
+
78
+ if __name__ == "__main__":
79
+ mcp.run()
@@ -0,0 +1,54 @@
1
+ import ast
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from api2mcp.generator import render_server, write_server
7
+ from api2mcp.parser import parse_spec
8
+
9
+ SPEC = {
10
+ "openapi": "3.0.0",
11
+ "info": {"title": "Order API"},
12
+ "servers": [{"url": "https://api.example.com"}],
13
+ "paths": {
14
+ "/orders/{orderId}": {
15
+ "delete": {
16
+ "operationId": "deleteOrder",
17
+ "parameters": [
18
+ {"name": "apiKey", "in": "header", "required": False, "schema": {"type": "string"}},
19
+ {"name": "orderId", "in": "path", "required": True, "schema": {"type": "integer"}},
20
+ ],
21
+ }
22
+ }
23
+ },
24
+ }
25
+
26
+
27
+ @pytest.fixture
28
+ def spec_file(tmp_path):
29
+ p = tmp_path / "spec.json"
30
+ p.write_text(json.dumps(SPEC))
31
+ return str(p)
32
+
33
+
34
+ def test_generated_code_is_valid_python(spec_file):
35
+ spec = parse_spec(spec_file)
36
+ code = render_server(spec)
37
+ ast.parse(code) # raises SyntaxError if invalid
38
+
39
+
40
+ def test_required_params_ordered_before_optional(spec_file):
41
+ """Regression: optional param (apiKey) appears before required (orderId) in
42
+ the raw OpenAPI param list, but Python needs required args first."""
43
+ spec = parse_spec(spec_file)
44
+ code = render_server(spec)
45
+ ast.parse(code)
46
+ assert "def deleteorder(orderId: int, apiKey: str = \"\")" in code
47
+
48
+
49
+ def test_write_server_creates_files(tmp_path, spec_file):
50
+ spec = parse_spec(spec_file)
51
+ server_file = write_server(spec, str(tmp_path / "out"))
52
+ assert server_file.exists()
53
+ assert (tmp_path / "out" / "README.md").exists()
54
+ ast.parse(server_file.read_text())
@@ -0,0 +1,93 @@
1
+ import json
2
+ import textwrap
3
+
4
+ import pytest
5
+
6
+ from api2mcp.parser import parse_spec
7
+
8
+ SPEC = {
9
+ "openapi": "3.0.0",
10
+ "info": {"title": "Test API"},
11
+ "servers": [{"url": "/v1"}],
12
+ "paths": {
13
+ "/pets/{petId}": {
14
+ "get": {
15
+ "operationId": "getPet",
16
+ "summary": "Get a pet",
17
+ "parameters": [
18
+ {"name": "petId", "in": "path", "required": True, "schema": {"type": "integer"}},
19
+ {"name": "verbose", "in": "query", "required": False, "schema": {"type": "boolean"}},
20
+ ],
21
+ }
22
+ },
23
+ "/pets": {
24
+ "post": {
25
+ "operationId": "createPet",
26
+ "requestBody": {
27
+ "content": {
28
+ "application/json": {
29
+ "schema": {
30
+ "type": "object",
31
+ "required": ["name"],
32
+ "properties": {
33
+ "name": {"type": "string"},
34
+ "age": {"type": "integer"},
35
+ },
36
+ }
37
+ }
38
+ }
39
+ },
40
+ }
41
+ },
42
+ },
43
+ }
44
+
45
+
46
+ @pytest.fixture
47
+ def spec_file(tmp_path):
48
+ p = tmp_path / "spec.json"
49
+ p.write_text(json.dumps(SPEC))
50
+ return str(p)
51
+
52
+
53
+ def test_parse_spec_basic(spec_file):
54
+ spec = parse_spec(spec_file)
55
+ assert spec.title == "Test API"
56
+ assert len(spec.operations) == 2
57
+
58
+
59
+ def test_base_url_joins_relative_server_with_source_origin(monkeypatch, tmp_path):
60
+ """Regression: a relative `servers[].url` (e.g. "/api/v3") must be joined
61
+ with the spec source's origin, not silently dropped."""
62
+ import api2mcp.parser as parser_mod
63
+
64
+ monkeypatch.setattr(parser_mod, "_load_raw", lambda source: SPEC)
65
+ spec = parser_mod.parse_spec("https://example.com/openapi.json")
66
+ assert spec.base_url == "https://example.com/v1"
67
+
68
+
69
+ def test_path_and_query_params(spec_file):
70
+ spec = parse_spec(spec_file)
71
+ op = next(o for o in spec.operations if o.op_id == "getPet")
72
+ assert op.method == "get"
73
+ locs = {p.name: p.location for p in op.params}
74
+ assert locs["petId"] == "path"
75
+ assert locs["verbose"] == "query"
76
+ required = {p.name: p.required for p in op.params}
77
+ assert required["petId"] is True
78
+ assert required["verbose"] is False
79
+
80
+
81
+ def test_body_params_flattened(spec_file):
82
+ spec = parse_spec(spec_file)
83
+ op = next(o for o in spec.operations if o.op_id == "createPet")
84
+ body_params = {p.name: p for p in op.params if p.location == "body"}
85
+ assert body_params["name"].required is True
86
+ assert body_params["age"].required is False
87
+ assert body_params["age"].py_type == "int"
88
+
89
+
90
+ def test_func_name_sanitized(spec_file):
91
+ spec = parse_spec(spec_file)
92
+ op = next(o for o in spec.operations if o.op_id == "getPet")
93
+ assert op.func_name == "getpet"