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.
@@ -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,5 @@
1
+ """MCP RESTful Wrapper — convert OpenAPI/Swagger specs into MCP Servers."""
2
+
3
+ from mcp_restful_wrapper._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -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
+ )