mcp-restful-wrapper 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.
- mcp_restful_wrapper-0.1.0/PKG-INFO +165 -0
- mcp_restful_wrapper-0.1.0/README.md +140 -0
- mcp_restful_wrapper-0.1.0/pyproject.toml +46 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/__init__.py +5 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/_version.py +1 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/cli.py +88 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/logging.py +48 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/server.py +160 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/spec_converter.py +564 -0
- mcp_restful_wrapper-0.1.0/src/mcp_restful_wrapper/spec_fetcher.py +55 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mcp-restful-wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatically convert RESTful APIs (Swagger 2.0 / OpenAPI 3.0) into MCP Servers
|
|
5
|
+
Keywords: mcp,openapi,swagger,restful,api,model-context-protocol
|
|
6
|
+
Author: lixw
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Dist: fastmcp>=3.4.2
|
|
18
|
+
Requires-Dist: httpx>=0.28.1
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/alvinlee518/mcp-restful-wrapper
|
|
22
|
+
Project-URL: Repository, https://github.com/alvinlee518/mcp-restful-wrapper
|
|
23
|
+
Project-URL: Issues, https://github.com/alvinlee518/mcp-restful-wrapper/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# MCP RESTful Wrapper
|
|
27
|
+
|
|
28
|
+
将 RESTful API(Swagger 2.0 / OpenAPI 3.0)自动转换为 MCP Server。每个 API 端点变成一个 MCP Tool。
|
|
29
|
+
|
|
30
|
+
## 快速开始
|
|
31
|
+
|
|
32
|
+
### 1. 在 Claude Desktop / Cursor / Claude Code 中使用
|
|
33
|
+
|
|
34
|
+
编辑配置文件(Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
35
|
+
|
|
36
|
+
**从 PyPI 安装(推荐):**
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"petstore": {
|
|
42
|
+
"command": "uvx",
|
|
43
|
+
"args": ["mcp-restful-wrapper"],
|
|
44
|
+
"env": {
|
|
45
|
+
"API_SPEC_URL": "https://petstore3.swagger.io/api/v3/openapi.json",
|
|
46
|
+
"API_BASE_URL": "https://petstore3.swagger.io/api/v3",
|
|
47
|
+
"API_TAGS": "pet,store"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**本地开发(未发布时):**
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"petstore": {
|
|
60
|
+
"command": "uv",
|
|
61
|
+
"args": [
|
|
62
|
+
"run",
|
|
63
|
+
"--project", "/path/to/mcp-restful-wrapper",
|
|
64
|
+
"mcp-restful-wrapper"
|
|
65
|
+
],
|
|
66
|
+
"env": {
|
|
67
|
+
"API_SPEC_URL": "https://petstore3.swagger.io/api/v3/openapi.json",
|
|
68
|
+
"API_BASE_URL": "https://petstore3.swagger.io/api/v3",
|
|
69
|
+
"API_TAGS": "pet,store"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
> 将 `/path/to/mcp-restful-wrapper` 替换为本项目的实际路径。
|
|
77
|
+
|
|
78
|
+
配置完成后,Claude 就能直接调用 Petstore 的 API 了:
|
|
79
|
+
|
|
80
|
+
- `findPetsByStatus(status="available")` → 查询可用宠物
|
|
81
|
+
- `getPetById(petId=1)` → 查询宠物详情
|
|
82
|
+
- `addPet(name="Buddy", ...)` → 创建宠物
|
|
83
|
+
|
|
84
|
+
### 2. 在终端中使用
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# PyPI 版本
|
|
88
|
+
API_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \
|
|
89
|
+
API_BASE_URL=https://petstore3.swagger.io/api/v3 \
|
|
90
|
+
API_TAGS=pet \
|
|
91
|
+
uvx mcp-restful-wrapper
|
|
92
|
+
|
|
93
|
+
# 本地开发
|
|
94
|
+
API_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \
|
|
95
|
+
API_BASE_URL=https://petstore3.swagger.io/api/v3 \
|
|
96
|
+
API_TAGS=pet \
|
|
97
|
+
uv run mcp-restful-wrapper
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. 带认证的 API
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"my-api": {
|
|
106
|
+
"command": "uvx",
|
|
107
|
+
"args": ["mcp-restful-wrapper"],
|
|
108
|
+
"env": {
|
|
109
|
+
"API_SPEC_URL": "https://your-api.example.com/openapi.json",
|
|
110
|
+
"API_BASE_URL": "https://your-api.example.com",
|
|
111
|
+
"API_TOKEN": "your-bearer-token"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## 环境变量
|
|
119
|
+
|
|
120
|
+
| 变量 | 说明 | 默认值 |
|
|
121
|
+
|------|------|--------|
|
|
122
|
+
| `API_SPEC_URL` | OpenAPI/Swagger 文档地址 | **必填** |
|
|
123
|
+
| `API_BASE_URL` | 后端 API 地址 | **必填** |
|
|
124
|
+
| `API_TOKEN` | Bearer Token 认证 | 空 |
|
|
125
|
+
| `API_TAGS` | Tag 白名单,逗号分隔,**OR** 逻辑(匹配任一 tag 即保留) | 全部 |
|
|
126
|
+
| `API_METHODS` | HTTP 方法白名单,逗号分隔 | 全部 |
|
|
127
|
+
| `API_PATHS` | 路径正则白名单(如 `^/api/v1/`) | 全部 |
|
|
128
|
+
| `LOG_LEVEL` | 日志级别(`INFO` 记录请求 URL,`DEBUG` 记录请求头和 body) | `WARNING` |
|
|
129
|
+
|
|
130
|
+
### 过滤示例
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# 只要 GET 请求
|
|
134
|
+
API_METHODS=GET
|
|
135
|
+
|
|
136
|
+
# 只要 pet 或 store 相关的端点
|
|
137
|
+
API_TAGS=pet,store
|
|
138
|
+
|
|
139
|
+
# 只要 /api/v1/ 开头的路径
|
|
140
|
+
API_PATHS=^/api/v1/
|
|
141
|
+
|
|
142
|
+
# 组合使用:pet tag 下的 GET 和 POST
|
|
143
|
+
API_TAGS=pet
|
|
144
|
+
API_METHODS=GET,POST
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 开发
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
uv sync # 安装依赖
|
|
151
|
+
uv run mcp-restful-wrapper # 运行
|
|
152
|
+
uv run pytest tests/ -v # 运行测试
|
|
153
|
+
uv run pytest tests/ --cov=mcp_restful_wrapper # 覆盖率
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## 示例代码
|
|
157
|
+
|
|
158
|
+
| 文件 | 说明 |
|
|
159
|
+
|------|------|
|
|
160
|
+
| [`examples/agent_usage.py`](examples/agent_usage.py) | Agent 中使用 — 连接 MCP Server、列出 tools、调用 tools |
|
|
161
|
+
| [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json) | Claude Desktop 配置模板 |
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
uv run python examples/agent_usage.py
|
|
165
|
+
```
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# MCP RESTful Wrapper
|
|
2
|
+
|
|
3
|
+
将 RESTful API(Swagger 2.0 / OpenAPI 3.0)自动转换为 MCP Server。每个 API 端点变成一个 MCP Tool。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
### 1. 在 Claude Desktop / Cursor / Claude Code 中使用
|
|
8
|
+
|
|
9
|
+
编辑配置文件(Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
10
|
+
|
|
11
|
+
**从 PyPI 安装(推荐):**
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"petstore": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["mcp-restful-wrapper"],
|
|
19
|
+
"env": {
|
|
20
|
+
"API_SPEC_URL": "https://petstore3.swagger.io/api/v3/openapi.json",
|
|
21
|
+
"API_BASE_URL": "https://petstore3.swagger.io/api/v3",
|
|
22
|
+
"API_TAGS": "pet,store"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**本地开发(未发布时):**
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"petstore": {
|
|
35
|
+
"command": "uv",
|
|
36
|
+
"args": [
|
|
37
|
+
"run",
|
|
38
|
+
"--project", "/path/to/mcp-restful-wrapper",
|
|
39
|
+
"mcp-restful-wrapper"
|
|
40
|
+
],
|
|
41
|
+
"env": {
|
|
42
|
+
"API_SPEC_URL": "https://petstore3.swagger.io/api/v3/openapi.json",
|
|
43
|
+
"API_BASE_URL": "https://petstore3.swagger.io/api/v3",
|
|
44
|
+
"API_TAGS": "pet,store"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> 将 `/path/to/mcp-restful-wrapper` 替换为本项目的实际路径。
|
|
52
|
+
|
|
53
|
+
配置完成后,Claude 就能直接调用 Petstore 的 API 了:
|
|
54
|
+
|
|
55
|
+
- `findPetsByStatus(status="available")` → 查询可用宠物
|
|
56
|
+
- `getPetById(petId=1)` → 查询宠物详情
|
|
57
|
+
- `addPet(name="Buddy", ...)` → 创建宠物
|
|
58
|
+
|
|
59
|
+
### 2. 在终端中使用
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# PyPI 版本
|
|
63
|
+
API_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \
|
|
64
|
+
API_BASE_URL=https://petstore3.swagger.io/api/v3 \
|
|
65
|
+
API_TAGS=pet \
|
|
66
|
+
uvx mcp-restful-wrapper
|
|
67
|
+
|
|
68
|
+
# 本地开发
|
|
69
|
+
API_SPEC_URL=https://petstore3.swagger.io/api/v3/openapi.json \
|
|
70
|
+
API_BASE_URL=https://petstore3.swagger.io/api/v3 \
|
|
71
|
+
API_TAGS=pet \
|
|
72
|
+
uv run mcp-restful-wrapper
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 3. 带认证的 API
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"my-api": {
|
|
81
|
+
"command": "uvx",
|
|
82
|
+
"args": ["mcp-restful-wrapper"],
|
|
83
|
+
"env": {
|
|
84
|
+
"API_SPEC_URL": "https://your-api.example.com/openapi.json",
|
|
85
|
+
"API_BASE_URL": "https://your-api.example.com",
|
|
86
|
+
"API_TOKEN": "your-bearer-token"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 环境变量
|
|
94
|
+
|
|
95
|
+
| 变量 | 说明 | 默认值 |
|
|
96
|
+
|------|------|--------|
|
|
97
|
+
| `API_SPEC_URL` | OpenAPI/Swagger 文档地址 | **必填** |
|
|
98
|
+
| `API_BASE_URL` | 后端 API 地址 | **必填** |
|
|
99
|
+
| `API_TOKEN` | Bearer Token 认证 | 空 |
|
|
100
|
+
| `API_TAGS` | Tag 白名单,逗号分隔,**OR** 逻辑(匹配任一 tag 即保留) | 全部 |
|
|
101
|
+
| `API_METHODS` | HTTP 方法白名单,逗号分隔 | 全部 |
|
|
102
|
+
| `API_PATHS` | 路径正则白名单(如 `^/api/v1/`) | 全部 |
|
|
103
|
+
| `LOG_LEVEL` | 日志级别(`INFO` 记录请求 URL,`DEBUG` 记录请求头和 body) | `WARNING` |
|
|
104
|
+
|
|
105
|
+
### 过滤示例
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 只要 GET 请求
|
|
109
|
+
API_METHODS=GET
|
|
110
|
+
|
|
111
|
+
# 只要 pet 或 store 相关的端点
|
|
112
|
+
API_TAGS=pet,store
|
|
113
|
+
|
|
114
|
+
# 只要 /api/v1/ 开头的路径
|
|
115
|
+
API_PATHS=^/api/v1/
|
|
116
|
+
|
|
117
|
+
# 组合使用:pet tag 下的 GET 和 POST
|
|
118
|
+
API_TAGS=pet
|
|
119
|
+
API_METHODS=GET,POST
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 开发
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv sync # 安装依赖
|
|
126
|
+
uv run mcp-restful-wrapper # 运行
|
|
127
|
+
uv run pytest tests/ -v # 运行测试
|
|
128
|
+
uv run pytest tests/ --cov=mcp_restful_wrapper # 覆盖率
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 示例代码
|
|
132
|
+
|
|
133
|
+
| 文件 | 说明 |
|
|
134
|
+
|------|------|
|
|
135
|
+
| [`examples/agent_usage.py`](examples/agent_usage.py) | Agent 中使用 — 连接 MCP Server、列出 tools、调用 tools |
|
|
136
|
+
| [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json) | Claude Desktop 配置模板 |
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
uv run python examples/agent_usage.py
|
|
140
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-restful-wrapper"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Automatically convert RESTful APIs (Swagger 2.0 / OpenAPI 3.0) into MCP Servers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "lixw"},
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mcp", "openapi", "swagger", "restful", "api", "model-context-protocol"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"fastmcp>=3.4.2",
|
|
25
|
+
"httpx>=0.28.1",
|
|
26
|
+
"pyyaml>=6.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/alvinlee518/mcp-restful-wrapper"
|
|
31
|
+
Repository = "https://github.com/alvinlee518/mcp-restful-wrapper"
|
|
32
|
+
Issues = "https://github.com/alvinlee518/mcp-restful-wrapper/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
mcp-restful-wrapper = "mcp_restful_wrapper.cli:main"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=9.1.1",
|
|
44
|
+
"pytest-asyncio>=1.4.0",
|
|
45
|
+
"pytest-cov>=7.1.0",
|
|
46
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""CLI entry point for mcp-restful-wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from mcp_restful_wrapper.logging import setup_logging
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""Entry point: fetch spec, convert if needed, build and run MCP server."""
|
|
13
|
+
|
|
14
|
+
setup_logging()
|
|
15
|
+
|
|
16
|
+
# 1. Read environment variables
|
|
17
|
+
spec_url = os.environ.get("API_SPEC_URL")
|
|
18
|
+
if not spec_url:
|
|
19
|
+
print("Error: API_SPEC_URL environment variable is required.", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
base_url = os.environ.get("API_BASE_URL")
|
|
23
|
+
if not base_url:
|
|
24
|
+
print("Error: API_BASE_URL environment variable is required.", file=sys.stderr)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
token = os.environ.get("API_TOKEN")
|
|
28
|
+
tags_str = os.environ.get("API_TAGS")
|
|
29
|
+
methods_str = os.environ.get("API_METHODS")
|
|
30
|
+
paths_pattern = os.environ.get("API_PATHS")
|
|
31
|
+
|
|
32
|
+
# Parse filter values
|
|
33
|
+
tags = {t.strip() for t in tags_str.split(",") if t.strip()} if tags_str else None
|
|
34
|
+
methods = (
|
|
35
|
+
{m.strip().upper() for m in methods_str.split(",") if m.strip()}
|
|
36
|
+
if methods_str
|
|
37
|
+
else None
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# 2. Fetch the spec
|
|
41
|
+
from mcp_restful_wrapper.spec_fetcher import fetch_spec
|
|
42
|
+
|
|
43
|
+
print(f"Fetching spec from {spec_url}...", file=sys.stderr)
|
|
44
|
+
try:
|
|
45
|
+
spec = asyncio.run(fetch_spec(spec_url))
|
|
46
|
+
except Exception as e:
|
|
47
|
+
print(f"Error: Failed to fetch spec: {e}", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
print(
|
|
50
|
+
f"Spec loaded: {spec.get('info', {}).get('title', 'Unknown')}", file=sys.stderr
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# 3. Convert Swagger 2.0 → OpenAPI 3.0 if needed
|
|
54
|
+
from mcp_restful_wrapper.spec_converter import (
|
|
55
|
+
convert_swagger_to_openapi,
|
|
56
|
+
is_swagger_2,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if is_swagger_2(spec):
|
|
60
|
+
print("Detected Swagger 2.0, converting to OpenAPI 3.0...", file=sys.stderr)
|
|
61
|
+
spec = convert_swagger_to_openapi(spec)
|
|
62
|
+
|
|
63
|
+
# 4. Build and run the MCP server (filtering via RouteMap inside)
|
|
64
|
+
from mcp_restful_wrapper.server import build_server
|
|
65
|
+
|
|
66
|
+
server_name = spec.get("info", {}).get("title", "RESTful API Server")
|
|
67
|
+
print(
|
|
68
|
+
f"Filters: tags={tags or 'all'}, methods={methods or 'all'}, "
|
|
69
|
+
f"paths={paths_pattern or 'all'}",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
mcp = build_server(
|
|
75
|
+
spec,
|
|
76
|
+
base_url=base_url,
|
|
77
|
+
token=token,
|
|
78
|
+
name=server_name,
|
|
79
|
+
tags=tags,
|
|
80
|
+
methods=methods,
|
|
81
|
+
paths=paths_pattern,
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"Error: Failed to build MCP server: {e}", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
print(f"Starting MCP server: {server_name}", file=sys.stderr)
|
|
88
|
+
mcp.run(transport="stdio", show_banner=False)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Logging configuration for mcp-restful-wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from logging.handlers import RotatingFileHandler
|
|
9
|
+
|
|
10
|
+
LOGGER_NAME = "mcp_restful_wrapper"
|
|
11
|
+
LOG_DIR = os.path.join(os.path.expanduser("~"), ".mcp_restful_wrapper")
|
|
12
|
+
os.makedirs(LOG_DIR, exist_ok=True)
|
|
13
|
+
LOG_FILE = os.path.join(LOG_DIR, "mcp_requests.log")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_logging() -> logging.Logger:
|
|
17
|
+
"""Configure logging based on LOG_LEVEL environment variable.
|
|
18
|
+
|
|
19
|
+
Sets up two handlers:
|
|
20
|
+
- RotatingFileHandler: writes to mcp_requests.log (10MB × 5 backups)
|
|
21
|
+
- StreamHandler: writes to stderr
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Configured logger instance.
|
|
25
|
+
"""
|
|
26
|
+
log_level = os.environ.get("LOG_LEVEL", "WARNING").upper()
|
|
27
|
+
level = getattr(logging, log_level, logging.WARNING)
|
|
28
|
+
log_format = "%(asctime)s %(levelname)s %(message)s"
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
31
|
+
logger.setLevel(level)
|
|
32
|
+
|
|
33
|
+
# File handler: rotating log file for request/response records
|
|
34
|
+
file_handler = RotatingFileHandler(
|
|
35
|
+
LOG_FILE,
|
|
36
|
+
maxBytes=10 * 1024 * 1024,
|
|
37
|
+
backupCount=5,
|
|
38
|
+
encoding="utf-8",
|
|
39
|
+
)
|
|
40
|
+
file_handler.setFormatter(logging.Formatter(log_format))
|
|
41
|
+
logger.addHandler(file_handler)
|
|
42
|
+
|
|
43
|
+
# Stderr handler: console output
|
|
44
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
45
|
+
stderr_handler.setFormatter(logging.Formatter(log_format))
|
|
46
|
+
logger.addHandler(stderr_handler)
|
|
47
|
+
|
|
48
|
+
return logger
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Build and run the MCP server from an OpenAPI specification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
|
|
11
|
+
|
|
12
|
+
from mcp_restful_wrapper._version import __version__ as _version
|
|
13
|
+
from mcp_restful_wrapper.logging import LOGGER_NAME
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def log_request(request: httpx.Request):
|
|
19
|
+
"""Log outgoing HTTP request details."""
|
|
20
|
+
body = (
|
|
21
|
+
request.content.decode("utf-8", errors="replace")
|
|
22
|
+
if request.content
|
|
23
|
+
else "(no body)"
|
|
24
|
+
)
|
|
25
|
+
logger.info(
|
|
26
|
+
"[REQUEST] %s %s\n Headers: %s\n Body: %s",
|
|
27
|
+
request.method,
|
|
28
|
+
request.url,
|
|
29
|
+
dict(request.headers),
|
|
30
|
+
body,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def log_response(response: httpx.Response):
|
|
35
|
+
"""Log incoming HTTP response details."""
|
|
36
|
+
# Read body if not already consumed
|
|
37
|
+
try:
|
|
38
|
+
await response.aread()
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
body = (
|
|
42
|
+
response.content.decode("utf-8", errors="replace")
|
|
43
|
+
if response.content
|
|
44
|
+
else "(no body)"
|
|
45
|
+
)
|
|
46
|
+
logger.info(
|
|
47
|
+
"[RESPONSE] %s %s\n Status: %d\n Body: %s",
|
|
48
|
+
response.request.method if response.request else "?",
|
|
49
|
+
response.url,
|
|
50
|
+
response.status_code,
|
|
51
|
+
body,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_server(
|
|
56
|
+
spec: dict[str, Any],
|
|
57
|
+
base_url: str | None = None,
|
|
58
|
+
token: str | None = None,
|
|
59
|
+
name: str = "RESTful API Server",
|
|
60
|
+
tags: set[str] | None = None,
|
|
61
|
+
methods: set[str] | None = None,
|
|
62
|
+
paths: str | None = None,
|
|
63
|
+
) -> FastMCP:
|
|
64
|
+
"""Build a FastMCP server from an OpenAPI specification.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
spec: The OpenAPI 3.x specification dict.
|
|
68
|
+
base_url: Base URL for the API.
|
|
69
|
+
token: Bearer token for API authentication.
|
|
70
|
+
name: Name for the MCP server.
|
|
71
|
+
tags: Set of tag names to include (OR logic — match ANY).
|
|
72
|
+
methods: Set of HTTP methods to include (case-insensitive).
|
|
73
|
+
paths: Regex pattern to match against path strings.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A configured FastMCP server instance.
|
|
77
|
+
"""
|
|
78
|
+
headers: dict[str, str] = {
|
|
79
|
+
"X-Requested-From": f"MCP/{_version}",
|
|
80
|
+
}
|
|
81
|
+
if token:
|
|
82
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
83
|
+
|
|
84
|
+
# Build httpx client
|
|
85
|
+
client_kwargs: dict[str, Any] = {
|
|
86
|
+
"headers": headers,
|
|
87
|
+
"timeout": 30.0,
|
|
88
|
+
"follow_redirects": True,
|
|
89
|
+
"event_hooks": {
|
|
90
|
+
"request": [log_request],
|
|
91
|
+
"response": [log_response],
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
if base_url:
|
|
95
|
+
client_kwargs["base_url"] = base_url
|
|
96
|
+
|
|
97
|
+
client = httpx.AsyncClient(**client_kwargs)
|
|
98
|
+
|
|
99
|
+
# Build RouteMaps for filtering
|
|
100
|
+
route_maps = _build_route_maps(tags, methods, paths)
|
|
101
|
+
|
|
102
|
+
# Create MCP server from OpenAPI spec
|
|
103
|
+
mcp = FastMCP.from_openapi(
|
|
104
|
+
openapi_spec=spec,
|
|
105
|
+
client=client,
|
|
106
|
+
name=name,
|
|
107
|
+
route_maps=route_maps,
|
|
108
|
+
validate_output=False,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return mcp
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_route_maps(
|
|
115
|
+
tags: set[str] | None,
|
|
116
|
+
methods: set[str] | None,
|
|
117
|
+
paths: str | None,
|
|
118
|
+
) -> list[RouteMap]:
|
|
119
|
+
"""Build RouteMap list for filtering endpoints.
|
|
120
|
+
|
|
121
|
+
Strategy:
|
|
122
|
+
- If tags specified: one RouteMap per tag (OR logic), matched first.
|
|
123
|
+
- If no tags: one RouteMap matching all (filtered by methods/paths).
|
|
124
|
+
- Final catch-all EXCLUDE rule drops everything unmatched.
|
|
125
|
+
"""
|
|
126
|
+
route_maps: list[RouteMap] = []
|
|
127
|
+
|
|
128
|
+
# Normalize methods to uppercase list, or "*" for all
|
|
129
|
+
allowed_methods: list[str] | str = "*"
|
|
130
|
+
if methods:
|
|
131
|
+
allowed_methods = [m.upper() for m in methods]
|
|
132
|
+
|
|
133
|
+
# Path pattern
|
|
134
|
+
pattern = paths or r".*"
|
|
135
|
+
|
|
136
|
+
if tags:
|
|
137
|
+
# OR logic: one RouteMap per tag — first match wins
|
|
138
|
+
for tag in tags:
|
|
139
|
+
route_maps.append(
|
|
140
|
+
RouteMap(
|
|
141
|
+
methods=allowed_methods,
|
|
142
|
+
pattern=pattern,
|
|
143
|
+
tags={tag},
|
|
144
|
+
mcp_type=MCPType.TOOL,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
# No tag filter: accept all routes matching methods/paths
|
|
149
|
+
route_maps.append(
|
|
150
|
+
RouteMap(
|
|
151
|
+
methods=allowed_methods,
|
|
152
|
+
pattern=pattern,
|
|
153
|
+
mcp_type=MCPType.TOOL,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Catch-all: exclude everything not matched above
|
|
158
|
+
route_maps.append(RouteMap(mcp_type=MCPType.EXCLUDE))
|
|
159
|
+
|
|
160
|
+
return route_maps
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Convert Swagger 2.0 specifications to OpenAPI 3.0 format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_swagger_2(spec: dict[str, Any]) -> bool:
|
|
11
|
+
"""Check if a spec is Swagger 2.0."""
|
|
12
|
+
return "swagger" in spec and str(spec["swagger"]).startswith("2")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def convert_swagger_to_openapi(spec: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
"""Convert a Swagger 2.0 specification to OpenAPI 3.0 format.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
spec: A Swagger 2.0 specification dict.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A new dict in OpenAPI 3.0 format.
|
|
23
|
+
"""
|
|
24
|
+
spec = copy.deepcopy(spec)
|
|
25
|
+
result: dict[str, Any] = {"openapi": "3.0.3"}
|
|
26
|
+
|
|
27
|
+
# 1. Info (pass through, fill in required fields)
|
|
28
|
+
info = spec.get("info", {})
|
|
29
|
+
if not isinstance(info, dict):
|
|
30
|
+
info = {}
|
|
31
|
+
info.setdefault("title", "API")
|
|
32
|
+
info.setdefault("version", "1.0.0")
|
|
33
|
+
# Clean up empty nested objects that fail validation
|
|
34
|
+
if isinstance(info.get("license"), dict) and "name" not in info["license"]:
|
|
35
|
+
info["license"] = {"name": "Unknown"}
|
|
36
|
+
result["info"] = info
|
|
37
|
+
|
|
38
|
+
# 2. Servers: host + basePath + schemes → servers[].url
|
|
39
|
+
result["servers"] = _convert_servers(spec)
|
|
40
|
+
|
|
41
|
+
# 3. Paths
|
|
42
|
+
result["paths"] = _convert_paths(spec)
|
|
43
|
+
|
|
44
|
+
# 4. Components
|
|
45
|
+
components = _convert_components(spec)
|
|
46
|
+
if components:
|
|
47
|
+
result["components"] = components
|
|
48
|
+
|
|
49
|
+
# 5. Security (pass through, rewrite $refs)
|
|
50
|
+
if "security" in spec:
|
|
51
|
+
result["security"] = spec["security"]
|
|
52
|
+
|
|
53
|
+
# 6. Tags (pass through)
|
|
54
|
+
if "tags" in spec:
|
|
55
|
+
result["tags"] = spec["tags"]
|
|
56
|
+
|
|
57
|
+
# 7. External docs (pass through)
|
|
58
|
+
if "externalDocs" in spec:
|
|
59
|
+
result["externalDocs"] = spec["externalDocs"]
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _convert_servers(spec: dict[str, Any]) -> list[dict[str, str]]:
|
|
65
|
+
"""Convert host/basePath/schemes to OpenAPI 3.0 servers array."""
|
|
66
|
+
host = spec.get("host", "")
|
|
67
|
+
base_path = spec.get("basePath", "")
|
|
68
|
+
schemes = spec.get("schemes", ["https"])
|
|
69
|
+
|
|
70
|
+
if not host:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
servers = []
|
|
74
|
+
for scheme in schemes:
|
|
75
|
+
url = f"{scheme}://{host}{base_path}".rstrip("/")
|
|
76
|
+
servers.append({"url": url})
|
|
77
|
+
|
|
78
|
+
return servers
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _convert_paths(spec: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""Convert all paths and their operations."""
|
|
83
|
+
paths: dict[str, Any] = {}
|
|
84
|
+
global_consumes = spec.get("consumes", ["application/json"])
|
|
85
|
+
global_produces = spec.get("produces", ["application/json"])
|
|
86
|
+
|
|
87
|
+
for path_key, path_item in spec.get("paths", {}).items():
|
|
88
|
+
new_path_item: dict[str, Any] = {}
|
|
89
|
+
|
|
90
|
+
# Path-level parameters (shared by all operations)
|
|
91
|
+
if "parameters" in path_item:
|
|
92
|
+
new_params = []
|
|
93
|
+
for param in path_item["parameters"]:
|
|
94
|
+
converted = _convert_parameter(param)
|
|
95
|
+
if converted:
|
|
96
|
+
new_params.append(converted)
|
|
97
|
+
if new_params:
|
|
98
|
+
new_path_item["parameters"] = new_params
|
|
99
|
+
|
|
100
|
+
# Convert each HTTP method operation
|
|
101
|
+
http_methods = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
102
|
+
for method in http_methods:
|
|
103
|
+
if method not in path_item:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
operation = path_item[method]
|
|
107
|
+
new_op = _convert_operation(
|
|
108
|
+
operation, global_consumes, global_produces
|
|
109
|
+
)
|
|
110
|
+
new_path_item[method] = new_op
|
|
111
|
+
|
|
112
|
+
paths[path_key] = new_path_item
|
|
113
|
+
|
|
114
|
+
return paths
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _convert_operation(
|
|
118
|
+
operation: dict[str, Any],
|
|
119
|
+
global_consumes: list[str],
|
|
120
|
+
global_produces: list[str],
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
"""Convert a single Swagger 2.0 operation to OpenAPI 3.0."""
|
|
123
|
+
new_op: dict[str, Any] = {}
|
|
124
|
+
|
|
125
|
+
# Pass-through fields
|
|
126
|
+
for field in ("tags", "summary", "description", "operationId", "deprecated",
|
|
127
|
+
"security"):
|
|
128
|
+
if field in operation:
|
|
129
|
+
new_op[field] = operation[field]
|
|
130
|
+
|
|
131
|
+
# External docs
|
|
132
|
+
if "externalDocs" in operation:
|
|
133
|
+
new_op["externalDocs"] = operation["externalDocs"]
|
|
134
|
+
|
|
135
|
+
consumes = operation.get("consumes", global_consumes)
|
|
136
|
+
produces = operation.get("produces", global_produces)
|
|
137
|
+
|
|
138
|
+
# Separate parameters: regular params vs body/formData
|
|
139
|
+
parameters = operation.get("parameters", [])
|
|
140
|
+
regular_params = []
|
|
141
|
+
body_params = []
|
|
142
|
+
form_params = []
|
|
143
|
+
|
|
144
|
+
for param in parameters:
|
|
145
|
+
param = _resolve_ref_param(param)
|
|
146
|
+
location = param.get("in", "")
|
|
147
|
+
if location == "body":
|
|
148
|
+
body_params.append(param)
|
|
149
|
+
elif location == "formData":
|
|
150
|
+
form_params.append(param)
|
|
151
|
+
else:
|
|
152
|
+
converted = _convert_parameter(param)
|
|
153
|
+
if converted:
|
|
154
|
+
regular_params.append(converted)
|
|
155
|
+
|
|
156
|
+
# Set parameters (path, query, header, cookie)
|
|
157
|
+
if regular_params:
|
|
158
|
+
new_op["parameters"] = regular_params
|
|
159
|
+
|
|
160
|
+
# Set requestBody from body or formData params
|
|
161
|
+
request_body = _build_request_body(body_params, form_params, consumes)
|
|
162
|
+
if request_body:
|
|
163
|
+
new_op["requestBody"] = request_body
|
|
164
|
+
|
|
165
|
+
# Convert responses
|
|
166
|
+
if "responses" in operation:
|
|
167
|
+
new_op["responses"] = _convert_responses(operation["responses"], produces)
|
|
168
|
+
else:
|
|
169
|
+
new_op["responses"] = {"200": {"description": "Successful response"}}
|
|
170
|
+
|
|
171
|
+
return new_op
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _convert_parameter(param: dict[str, Any]) -> dict[str, Any] | None:
|
|
175
|
+
"""Convert a Swagger 2.0 parameter to OpenAPI 3.0 format.
|
|
176
|
+
|
|
177
|
+
In Swagger 2.0, type/format/enum/items are inline on the parameter.
|
|
178
|
+
In OpenAPI 3.0, they're nested under a 'schema' key.
|
|
179
|
+
"""
|
|
180
|
+
param = _resolve_ref_param(param)
|
|
181
|
+
|
|
182
|
+
# If it's still a $ref after resolution, pass through as-is
|
|
183
|
+
if "$ref" in param:
|
|
184
|
+
return param
|
|
185
|
+
|
|
186
|
+
location = param.get("in", "")
|
|
187
|
+
|
|
188
|
+
# Skip body and formData — handled separately
|
|
189
|
+
if location in ("body", "formData"):
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
new_param: dict[str, Any] = {
|
|
193
|
+
"name": param["name"],
|
|
194
|
+
"in": location,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if param.get("description"):
|
|
198
|
+
new_param["description"] = param["description"]
|
|
199
|
+
if param.get("required"):
|
|
200
|
+
new_param["required"] = param["required"]
|
|
201
|
+
# Path params are always required in OpenAPI 3.0
|
|
202
|
+
if location == "path":
|
|
203
|
+
new_param["required"] = True
|
|
204
|
+
|
|
205
|
+
# Build the schema from inline type fields
|
|
206
|
+
schema = _extract_schema_from_swagger_param(param)
|
|
207
|
+
if schema:
|
|
208
|
+
new_param["schema"] = schema
|
|
209
|
+
|
|
210
|
+
# Convert collectionFormat to style/explode
|
|
211
|
+
if "collectionFormat" in param:
|
|
212
|
+
style, explode = _convert_collection_format(param["collectionFormat"])
|
|
213
|
+
if style:
|
|
214
|
+
new_param["style"] = style
|
|
215
|
+
new_param["explode"] = explode
|
|
216
|
+
|
|
217
|
+
# Allow empty values → not directly supported in 3.0, skip
|
|
218
|
+
# allowEmptyValue is deprecated in 3.0
|
|
219
|
+
|
|
220
|
+
return new_param
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_schema_from_swagger_param(param: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
"""Extract a JSON Schema from inline Swagger 2.0 parameter type fields."""
|
|
225
|
+
schema: dict[str, Any] = {}
|
|
226
|
+
|
|
227
|
+
for field in ("type", "format", "enum", "minimum", "maximum",
|
|
228
|
+
"minLength", "maxLength", "pattern", "default",
|
|
229
|
+
"minItems", "maxItems", "uniqueItems"):
|
|
230
|
+
if field in param:
|
|
231
|
+
schema[field] = param[field]
|
|
232
|
+
|
|
233
|
+
# Swagger 2.0 type: "file" → OpenAPI 3.0 type: "string", format: "binary"
|
|
234
|
+
if schema.get("type") == "file":
|
|
235
|
+
schema["type"] = "string"
|
|
236
|
+
schema["format"] = "binary"
|
|
237
|
+
|
|
238
|
+
# Handle array items
|
|
239
|
+
if "items" in param:
|
|
240
|
+
schema["items"] = _convert_schema_object(param["items"])
|
|
241
|
+
|
|
242
|
+
return schema
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _resolve_ref_param(param: dict[str, Any]) -> dict[str, Any]:
|
|
246
|
+
"""If param has a $ref, return the ref path rewritten for OpenAPI 3.0."""
|
|
247
|
+
if "$ref" in param:
|
|
248
|
+
ref = param["$ref"]
|
|
249
|
+
# #/parameters/Name → #/components/parameters/Name
|
|
250
|
+
new_ref = _rewrite_ref(ref)
|
|
251
|
+
return {"$ref": new_ref}
|
|
252
|
+
return param
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_request_body(
|
|
256
|
+
body_params: list[dict[str, Any]],
|
|
257
|
+
form_params: list[dict[str, Any]],
|
|
258
|
+
consumes: list[str],
|
|
259
|
+
) -> dict[str, Any] | None:
|
|
260
|
+
"""Build an OpenAPI 3.0 requestBody from Swagger 2.0 body/formData params."""
|
|
261
|
+
if body_params:
|
|
262
|
+
# Use the first body parameter's schema
|
|
263
|
+
body = body_params[0]
|
|
264
|
+
schema = body.get("schema", {})
|
|
265
|
+
schema = _convert_schema_object(schema)
|
|
266
|
+
|
|
267
|
+
# Build content map from consumes
|
|
268
|
+
content_types = _resolve_content_types(consumes, is_body=True)
|
|
269
|
+
content: dict[str, Any] = {}
|
|
270
|
+
for ct in content_types:
|
|
271
|
+
content[ct] = {"schema": schema}
|
|
272
|
+
|
|
273
|
+
request_body: dict[str, Any] = {"content": content}
|
|
274
|
+
if body.get("description"):
|
|
275
|
+
request_body["description"] = body["description"]
|
|
276
|
+
if body.get("required"):
|
|
277
|
+
request_body["required"] = True
|
|
278
|
+
|
|
279
|
+
return request_body
|
|
280
|
+
|
|
281
|
+
if form_params:
|
|
282
|
+
# Build schema from formData parameters
|
|
283
|
+
properties: dict[str, Any] = {}
|
|
284
|
+
required: list[str] = []
|
|
285
|
+
|
|
286
|
+
for param in form_params:
|
|
287
|
+
prop_schema = _extract_schema_from_swagger_param(param)
|
|
288
|
+
if param.get("description"):
|
|
289
|
+
prop_schema["description"] = param["description"]
|
|
290
|
+
properties[param["name"]] = prop_schema
|
|
291
|
+
if param.get("required"):
|
|
292
|
+
required.append(param["name"])
|
|
293
|
+
|
|
294
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
295
|
+
if required:
|
|
296
|
+
schema["required"] = required
|
|
297
|
+
|
|
298
|
+
# Determine content type for form data
|
|
299
|
+
has_file = any(p.get("type") == "file" for p in form_params)
|
|
300
|
+
content_type = "multipart/form-data" if has_file else "application/x-www-form-urlencoded"
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
"content": {
|
|
304
|
+
content_type: {"schema": schema}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _convert_responses(
|
|
312
|
+
responses: dict[str, Any],
|
|
313
|
+
produces: list[str],
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
"""Convert Swagger 2.0 responses to OpenAPI 3.0 format."""
|
|
316
|
+
new_responses: dict[str, Any] = {}
|
|
317
|
+
|
|
318
|
+
for status_code, response in responses.items():
|
|
319
|
+
new_resp: dict[str, Any] = {}
|
|
320
|
+
|
|
321
|
+
if "$ref" in response:
|
|
322
|
+
# Rewrite $ref for responses
|
|
323
|
+
new_resp["$ref"] = _rewrite_ref(response["$ref"])
|
|
324
|
+
new_responses[status_code] = new_resp
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
new_resp["description"] = response.get("description", "")
|
|
328
|
+
|
|
329
|
+
# Convert response schema to content
|
|
330
|
+
if "schema" in response:
|
|
331
|
+
schema = _convert_schema_object(response["schema"])
|
|
332
|
+
content_types = _resolve_content_types(produces)
|
|
333
|
+
content: dict[str, Any] = {}
|
|
334
|
+
for ct in content_types:
|
|
335
|
+
content[ct] = {"schema": schema}
|
|
336
|
+
new_resp["content"] = content
|
|
337
|
+
|
|
338
|
+
# Convert headers
|
|
339
|
+
if "headers" in response:
|
|
340
|
+
new_headers: dict[str, Any] = {}
|
|
341
|
+
for header_name, header_def in response["headers"].items():
|
|
342
|
+
new_header: dict[str, Any] = {}
|
|
343
|
+
if header_def.get("description"):
|
|
344
|
+
new_header["description"] = header_def["description"]
|
|
345
|
+
schema = _extract_schema_from_swagger_param(header_def)
|
|
346
|
+
if schema:
|
|
347
|
+
new_header["schema"] = schema
|
|
348
|
+
new_headers[header_name] = new_header
|
|
349
|
+
new_resp["headers"] = new_headers
|
|
350
|
+
|
|
351
|
+
new_responses[status_code] = new_resp
|
|
352
|
+
|
|
353
|
+
return new_responses
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _convert_components(spec: dict[str, Any]) -> dict[str, Any]:
|
|
357
|
+
"""Convert top-level Swagger 2.0 definitions to OpenAPI 3.0 components."""
|
|
358
|
+
components: dict[str, Any] = {}
|
|
359
|
+
|
|
360
|
+
# definitions → components.schemas
|
|
361
|
+
if "definitions" in spec:
|
|
362
|
+
schemas: dict[str, Any] = {}
|
|
363
|
+
for name, schema in spec["definitions"].items():
|
|
364
|
+
schemas[name] = _convert_schema_object(schema)
|
|
365
|
+
components["schemas"] = schemas
|
|
366
|
+
|
|
367
|
+
# parameters → components.parameters
|
|
368
|
+
if "parameters" in spec:
|
|
369
|
+
params: dict[str, Any] = {}
|
|
370
|
+
for name, param in spec["parameters"].items():
|
|
371
|
+
converted = _convert_parameter(param)
|
|
372
|
+
if converted:
|
|
373
|
+
params[name] = converted
|
|
374
|
+
if params:
|
|
375
|
+
components["parameters"] = params
|
|
376
|
+
|
|
377
|
+
# responses → components.responses
|
|
378
|
+
global_produces = spec.get("produces", ["application/json"])
|
|
379
|
+
if "responses" in spec:
|
|
380
|
+
resps: dict[str, Any] = {}
|
|
381
|
+
for name, response in spec["responses"].items():
|
|
382
|
+
resps[name] = _convert_responses({name: response}, global_produces)[name]
|
|
383
|
+
if resps:
|
|
384
|
+
components["responses"] = resps
|
|
385
|
+
|
|
386
|
+
# securityDefinitions → components.securitySchemes
|
|
387
|
+
if "securityDefinitions" in spec:
|
|
388
|
+
schemes: dict[str, Any] = {}
|
|
389
|
+
for name, scheme in spec["securityDefinitions"].items():
|
|
390
|
+
schemes[name] = _convert_security_scheme(scheme)
|
|
391
|
+
if schemes:
|
|
392
|
+
components["securitySchemes"] = schemes
|
|
393
|
+
|
|
394
|
+
return components
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _convert_schema_object(schema: dict[str, Any]) -> dict[str, Any]:
|
|
398
|
+
"""Recursively convert a Swagger 2.0 schema object to OpenAPI 3.0.
|
|
399
|
+
|
|
400
|
+
Handles $ref rewriting and nested schema objects.
|
|
401
|
+
"""
|
|
402
|
+
if not isinstance(schema, dict):
|
|
403
|
+
return schema
|
|
404
|
+
|
|
405
|
+
result: dict[str, Any] = {}
|
|
406
|
+
|
|
407
|
+
for key, value in schema.items():
|
|
408
|
+
if key == "$ref" and isinstance(value, str):
|
|
409
|
+
if value.startswith("#/"):
|
|
410
|
+
result["$ref"] = _rewrite_ref(value)
|
|
411
|
+
else:
|
|
412
|
+
# Non-standard $ref (e.g., Springfox Java types)
|
|
413
|
+
resolved = _resolve_java_type_ref(value)
|
|
414
|
+
result.update(resolved)
|
|
415
|
+
elif key == "items" and isinstance(value, dict):
|
|
416
|
+
result["items"] = _convert_schema_object(value)
|
|
417
|
+
elif key in ("properties",) and isinstance(value, dict):
|
|
418
|
+
result[key] = {
|
|
419
|
+
k: _convert_schema_object(v) for k, v in value.items()
|
|
420
|
+
}
|
|
421
|
+
elif key == "additionalProperties" and isinstance(value, dict):
|
|
422
|
+
result[key] = _convert_schema_object(value)
|
|
423
|
+
elif key in ("allOf", "anyOf", "oneOf") and isinstance(value, list):
|
|
424
|
+
result[key] = [_convert_schema_object(item) for item in value]
|
|
425
|
+
else:
|
|
426
|
+
result[key] = value
|
|
427
|
+
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _rewrite_ref(ref: str) -> str:
|
|
432
|
+
"""Rewrite Swagger 2.0 $ref paths to OpenAPI 3.0 paths."""
|
|
433
|
+
replacements = [
|
|
434
|
+
(r"^#/definitions/", "#/components/schemas/"),
|
|
435
|
+
(r"^#/parameters/", "#/components/parameters/"),
|
|
436
|
+
(r"^#/responses/", "#/components/responses/"),
|
|
437
|
+
]
|
|
438
|
+
for pattern, replacement in replacements:
|
|
439
|
+
ref = re.sub(pattern, replacement, ref)
|
|
440
|
+
return ref
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# Mapping of common Java/Springfox type names to OpenAPI schemas
|
|
444
|
+
_JAVA_TYPE_MAP = {
|
|
445
|
+
"LocalDate": {"type": "string", "format": "date"},
|
|
446
|
+
"LocalDateTime": {"type": "string", "format": "date-time"},
|
|
447
|
+
"LocalTime": {"type": "string", "format": "time"},
|
|
448
|
+
"Instant": {"type": "string", "format": "date-time"},
|
|
449
|
+
"ZonedDateTime": {"type": "string", "format": "date-time"},
|
|
450
|
+
"OffsetDateTime": {"type": "string", "format": "date-time"},
|
|
451
|
+
"BigDecimal": {"type": "number"},
|
|
452
|
+
"BigInteger": {"type": "integer"},
|
|
453
|
+
"UUID": {"type": "string", "format": "uuid"},
|
|
454
|
+
"URI": {"type": "string", "format": "uri"},
|
|
455
|
+
"URL": {"type": "string", "format": "uri"},
|
|
456
|
+
"MultipartFile": {"type": "string", "format": "binary"},
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _resolve_java_type_ref(ref: str) -> dict[str, Any]:
|
|
461
|
+
"""Resolve a non-standard $ref (e.g., Springfox Java type) to an OpenAPI schema.
|
|
462
|
+
|
|
463
|
+
Handles refs like: Error-ModelName{namespace='java.time', name='LocalDate'}
|
|
464
|
+
"""
|
|
465
|
+
# Extract the type name from Springfox format
|
|
466
|
+
match = re.search(r"name='(\w+)'", ref)
|
|
467
|
+
type_name = match.group(1) if match else ""
|
|
468
|
+
|
|
469
|
+
if type_name in _JAVA_TYPE_MAP:
|
|
470
|
+
return dict(_JAVA_TYPE_MAP[type_name])
|
|
471
|
+
|
|
472
|
+
# Unknown Java type — fall back to string
|
|
473
|
+
return {"type": "string"}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _convert_security_scheme(scheme: dict[str, Any]) -> dict[str, Any]:
|
|
477
|
+
"""Convert a Swagger 2.0 security scheme to OpenAPI 3.0."""
|
|
478
|
+
new_scheme: dict[str, Any] = {}
|
|
479
|
+
|
|
480
|
+
scheme_type = scheme.get("type", "")
|
|
481
|
+
|
|
482
|
+
if scheme_type == "basic":
|
|
483
|
+
new_scheme["type"] = "http"
|
|
484
|
+
new_scheme["scheme"] = "basic"
|
|
485
|
+
elif scheme_type == "apiKey":
|
|
486
|
+
new_scheme["type"] = "apiKey"
|
|
487
|
+
new_scheme["name"] = scheme.get("name", "")
|
|
488
|
+
new_scheme["in"] = scheme.get("in", "header")
|
|
489
|
+
elif scheme_type == "oauth2":
|
|
490
|
+
new_scheme["type"] = "oauth2"
|
|
491
|
+
flows = _convert_oauth2_flows(scheme)
|
|
492
|
+
if flows:
|
|
493
|
+
new_scheme["flows"] = flows
|
|
494
|
+
else:
|
|
495
|
+
new_scheme = scheme
|
|
496
|
+
|
|
497
|
+
if scheme.get("description"):
|
|
498
|
+
new_scheme["description"] = scheme["description"]
|
|
499
|
+
|
|
500
|
+
return new_scheme
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _convert_oauth2_flows(scheme: dict[str, Any]) -> dict[str, Any]:
|
|
504
|
+
"""Convert Swagger 2.0 OAuth2 flow types to OpenAPI 3.0 flow names."""
|
|
505
|
+
flow_type = scheme.get("flow", "")
|
|
506
|
+
flow: dict[str, Any] = {}
|
|
507
|
+
|
|
508
|
+
if scheme.get("scopes"):
|
|
509
|
+
flow["scopes"] = scheme["scopes"]
|
|
510
|
+
|
|
511
|
+
# Map old flow names to new
|
|
512
|
+
flow_name_map = {
|
|
513
|
+
"implicit": "implicit",
|
|
514
|
+
"password": "password",
|
|
515
|
+
"application": "clientCredentials",
|
|
516
|
+
"accessCode": "authorizationCode",
|
|
517
|
+
}
|
|
518
|
+
flow_name = flow_name_map.get(flow_type, flow_type)
|
|
519
|
+
|
|
520
|
+
# Map URLs
|
|
521
|
+
if scheme.get("authorizationUrl"):
|
|
522
|
+
flow["authorizationUrl"] = scheme["authorizationUrl"]
|
|
523
|
+
if scheme.get("tokenUrl"):
|
|
524
|
+
flow["tokenUrl"] = scheme["tokenUrl"]
|
|
525
|
+
|
|
526
|
+
return {flow_name: flow}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _resolve_content_types(
|
|
530
|
+
consumes: list[str], *, is_body: bool = False
|
|
531
|
+
) -> list[str]:
|
|
532
|
+
"""Resolve content types from Swagger 2.0 consumes/produces list.
|
|
533
|
+
|
|
534
|
+
Returns a list of valid content types for OpenAPI 3.0.
|
|
535
|
+
"""
|
|
536
|
+
if not consumes:
|
|
537
|
+
return ["application/json"]
|
|
538
|
+
|
|
539
|
+
result = []
|
|
540
|
+
for ct in consumes:
|
|
541
|
+
# Skip form-related types for body params (they belong to formData)
|
|
542
|
+
if is_body and ct in (
|
|
543
|
+
"multipart/form-data",
|
|
544
|
+
"application/x-www-form-urlencoded",
|
|
545
|
+
):
|
|
546
|
+
continue
|
|
547
|
+
result.append(ct)
|
|
548
|
+
|
|
549
|
+
return result or ["application/json"]
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _convert_collection_format(
|
|
553
|
+
fmt: str,
|
|
554
|
+
) -> tuple[str | None, bool]:
|
|
555
|
+
"""Convert Swagger 2.0 collectionFormat to OpenAPI 3.0 style/explode."""
|
|
556
|
+
mapping = {
|
|
557
|
+
"csv": ("form", False),
|
|
558
|
+
"ssv": ("spaceDelimited", False),
|
|
559
|
+
"tsv": ("pipeDelimited", False), # closest approximation
|
|
560
|
+
"pipes": ("pipeDelimited", False),
|
|
561
|
+
"multi": ("form", True),
|
|
562
|
+
}
|
|
563
|
+
style, explode = mapping.get(fmt, (None, False))
|
|
564
|
+
return style, explode
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Fetch and parse OpenAPI/Swagger specifications from URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def fetch_spec(url: str) -> dict[str, Any]:
|
|
12
|
+
"""Fetch an API specification from a URL and parse it as JSON or YAML.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
url: The URL to fetch the specification from.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The parsed specification as a dictionary.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
httpx.HTTPStatusError: If the HTTP request fails.
|
|
22
|
+
ValueError: If the response cannot be parsed as JSON or YAML.
|
|
23
|
+
"""
|
|
24
|
+
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
|
25
|
+
response = await client.get(url)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
|
|
28
|
+
content_type = response.headers.get("content-type", "")
|
|
29
|
+
text = response.text
|
|
30
|
+
|
|
31
|
+
# Try JSON first
|
|
32
|
+
if "json" in content_type or url.endswith(".json"):
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(text)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# Fall back to YAML
|
|
39
|
+
try:
|
|
40
|
+
import yaml
|
|
41
|
+
|
|
42
|
+
result = yaml.safe_load(text)
|
|
43
|
+
if isinstance(result, dict):
|
|
44
|
+
return result
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# Last resort: try JSON again (some servers return JSON with wrong content-type)
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(text)
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Failed to parse spec from {url} as JSON or YAML. "
|
|
54
|
+
f"Content-Type: {content_type}"
|
|
55
|
+
)
|