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.
- spec2mcp-0.1.0/.gitignore +9 -0
- spec2mcp-0.1.0/LICENSE +21 -0
- spec2mcp-0.1.0/PKG-INFO +160 -0
- spec2mcp-0.1.0/README.md +136 -0
- spec2mcp-0.1.0/pyproject.toml +38 -0
- spec2mcp-0.1.0/src/api2mcp/__init__.py +5 -0
- spec2mcp-0.1.0/src/api2mcp/cli.py +39 -0
- spec2mcp-0.1.0/src/api2mcp/generator.py +53 -0
- spec2mcp-0.1.0/src/api2mcp/parser.py +149 -0
- spec2mcp-0.1.0/src/api2mcp/templates/server.py.j2 +79 -0
- spec2mcp-0.1.0/tests/test_generator.py +54 -0
- spec2mcp-0.1.0/tests/test_parser.py +93 -0
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.
|
spec2mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|
|
28
|
+
[](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).
|
spec2mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# api2mcp
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](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,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"
|