mcpify-openapi 1.0.0__py3-none-any.whl
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.
- mcpify/__init__.py +17 -0
- mcpify/api_server.py +139 -0
- mcpify/cli.py +238 -0
- mcpify/http_client.py +52 -0
- mcpify/spec.py +126 -0
- mcpify/tools.py +274 -0
- mcpify_openapi-1.0.0.dist-info/METADATA +208 -0
- mcpify_openapi-1.0.0.dist-info/RECORD +12 -0
- mcpify_openapi-1.0.0.dist-info/WHEEL +5 -0
- mcpify_openapi-1.0.0.dist-info/entry_points.txt +2 -0
- mcpify_openapi-1.0.0.dist-info/licenses/LICENSE +21 -0
- mcpify_openapi-1.0.0.dist-info/top_level.txt +1 -0
mcpify/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""mcpify β turn any OpenAPI REST API into an MCP server for AI agents."""
|
|
2
|
+
|
|
3
|
+
from .spec import SpecError, load_spec, resolve_ref, resolve_schema
|
|
4
|
+
from .tools import AuthConfig, RequestError, build_request, spec_to_tools
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SpecError",
|
|
9
|
+
"load_spec",
|
|
10
|
+
"resolve_ref",
|
|
11
|
+
"resolve_schema",
|
|
12
|
+
"AuthConfig",
|
|
13
|
+
"RequestError",
|
|
14
|
+
"build_request",
|
|
15
|
+
"spec_to_tools",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
mcpify/api_server.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) stdio server that exposes an OpenAPI spec.
|
|
2
|
+
|
|
3
|
+
Specks newline-delimited JSON-RPC 2.0 over stdio, as used by MCP stdio
|
|
4
|
+
transports. Every OpenAPI operation becomes an MCP tool that performs a
|
|
5
|
+
real HTTP call against the configured base URL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .http_client import execute, format_result
|
|
15
|
+
from .tools import AuthConfig, RequestError, build_request, spec_to_tools
|
|
16
|
+
|
|
17
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
18
|
+
SERVER_NAME = "mcpify"
|
|
19
|
+
SERVER_VERSION = "1.0.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiServer:
|
|
23
|
+
"""MCP handler backed by one OpenAPI specification."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
spec: dict,
|
|
28
|
+
base_url: str,
|
|
29
|
+
server_name: str = "mcpify",
|
|
30
|
+
auth: AuthConfig | None = None,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.spec = spec
|
|
34
|
+
self.base_url = base_url
|
|
35
|
+
self.server_name = server_name
|
|
36
|
+
self.auth = auth
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.tools = spec_to_tools(spec)
|
|
39
|
+
self.by_name = {tool["name"]: tool for tool in self.tools}
|
|
40
|
+
|
|
41
|
+
# -- public API used by the CLI --------------------------------------
|
|
42
|
+
@property
|
|
43
|
+
def tool_count(self) -> int:
|
|
44
|
+
return len(self.tools)
|
|
45
|
+
|
|
46
|
+
def public_tools(self) -> list[dict]:
|
|
47
|
+
return [
|
|
48
|
+
{k: v for k, v in tool.items() if not k.startswith("_")}
|
|
49
|
+
for tool in self.tools
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def call_tool(self, name: str, arguments: dict) -> tuple[str, bool]:
|
|
53
|
+
tool = self.by_name.get(name)
|
|
54
|
+
if tool is None:
|
|
55
|
+
raise KeyError(name)
|
|
56
|
+
request = build_request(self.base_url, tool["_meta"], arguments, self.auth)
|
|
57
|
+
if self.auth is not None:
|
|
58
|
+
request["url"] = self.auth.apply_query(request["url"])
|
|
59
|
+
result = execute(request, timeout=self.timeout)
|
|
60
|
+
return format_result(result)
|
|
61
|
+
|
|
62
|
+
# -- MCP plumbing -----------------------------------------------------
|
|
63
|
+
def _result(self, request_id: int | str | None, payload: dict) -> dict:
|
|
64
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": payload}
|
|
65
|
+
|
|
66
|
+
def _error(self, request_id: int | str | None, code: int, message: str) -> dict:
|
|
67
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
68
|
+
|
|
69
|
+
def _text(self, text: str, is_error: bool = False) -> dict:
|
|
70
|
+
payload: dict = {"content": [{"type": "text", "text": text}]}
|
|
71
|
+
if is_error:
|
|
72
|
+
payload["isError"] = True
|
|
73
|
+
return payload
|
|
74
|
+
|
|
75
|
+
def handle_message(self, message: dict) -> dict | None:
|
|
76
|
+
method = message.get("method", "")
|
|
77
|
+
request_id = message.get("id")
|
|
78
|
+
params = message.get("params") or {}
|
|
79
|
+
|
|
80
|
+
if method == "initialize":
|
|
81
|
+
requested = params.get("protocolVersion", PROTOCOL_VERSION)
|
|
82
|
+
return self._result(
|
|
83
|
+
request_id,
|
|
84
|
+
{
|
|
85
|
+
"protocolVersion": requested,
|
|
86
|
+
"capabilities": {"tools": {}},
|
|
87
|
+
"serverInfo": {"name": self.server_name, "version": SERVER_VERSION},
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
if method.startswith("notifications/"):
|
|
91
|
+
return None
|
|
92
|
+
if method == "ping":
|
|
93
|
+
return self._result(request_id, {})
|
|
94
|
+
if method == "tools/list":
|
|
95
|
+
return self._result(request_id, {"tools": self.public_tools()})
|
|
96
|
+
if method == "tools/call":
|
|
97
|
+
name = params.get("name", "")
|
|
98
|
+
arguments = params.get("arguments") or {}
|
|
99
|
+
try:
|
|
100
|
+
text, is_error = self.call_tool(name, arguments)
|
|
101
|
+
except KeyError:
|
|
102
|
+
return self._error(request_id, -32601, f"unknown tool: {name}")
|
|
103
|
+
except RequestError as err:
|
|
104
|
+
return self._result(request_id, self._text(str(err), is_error=True))
|
|
105
|
+
return self._result(request_id, self._text(text, is_error=is_error))
|
|
106
|
+
return self._error(request_id, -32601, f"method not found: {method}")
|
|
107
|
+
|
|
108
|
+
def serve(self, stdin: Any = None, stdout: Any = None) -> None:
|
|
109
|
+
input_stream = stdin if stdin is not None else sys.stdin
|
|
110
|
+
output_stream = stdout if stdout is not None else sys.stdout
|
|
111
|
+
for line in input_stream:
|
|
112
|
+
line = line.strip()
|
|
113
|
+
if not line:
|
|
114
|
+
continue
|
|
115
|
+
decoded: Any
|
|
116
|
+
response: dict | None
|
|
117
|
+
try:
|
|
118
|
+
decoded = json.loads(line)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
response = self._error(None, -32700, "parse error")
|
|
121
|
+
else:
|
|
122
|
+
response = self.handle_message(decoded)
|
|
123
|
+
if response is not None:
|
|
124
|
+
output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
125
|
+
output_stream.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def serve(
|
|
129
|
+
spec_path: str,
|
|
130
|
+
base_url: str,
|
|
131
|
+
name: str = "mcpify",
|
|
132
|
+
auth: AuthConfig | None = None,
|
|
133
|
+
timeout: float = 30.0,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Load the spec and block on the stdio loop (the `mcpify serve` entry)."""
|
|
136
|
+
from .spec import load_spec
|
|
137
|
+
|
|
138
|
+
spec = load_spec(spec_path)
|
|
139
|
+
ApiServer(spec, base_url, server_name=name, auth=auth, timeout=timeout).serve()
|
mcpify/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""mcpify command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .spec import SpecError, iter_operations, load_spec, spec_servers
|
|
13
|
+
from .tools import AuthConfig, spec_to_tools
|
|
14
|
+
|
|
15
|
+
USE_COLOR = sys.stdout.isatty()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fail(message: str, code: int = 2) -> NoReturn:
|
|
19
|
+
print(f"error: {message}", file=sys.stderr)
|
|
20
|
+
sys.exit(code)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def filter_tools(tools: list[dict], args: argparse.Namespace) -> list[dict]:
|
|
24
|
+
"""Apply --tag / --include / --exclude / --read-only / --allow / --deny filters.
|
|
25
|
+
|
|
26
|
+
Policy layering, weakest to strongest:
|
|
27
|
+
1. --read-only keeps GET operations only (a heuristic, not a guarantee).
|
|
28
|
+
2. --allow PATH_REGEX re-includes operations that --read-only dropped
|
|
29
|
+
(e.g. read-style POST endpoints).
|
|
30
|
+
3. --deny PATH_REGEX always excludes, and wins over everything.
|
|
31
|
+
"""
|
|
32
|
+
include = [p.rstrip("/") for p in (args.include or [])]
|
|
33
|
+
exclude = [p.rstrip("/") for p in (args.exclude or [])]
|
|
34
|
+
allow = [re.compile(p) for p in (getattr(args, "allow", None) or [])]
|
|
35
|
+
deny = [re.compile(p) for p in (getattr(args, "deny", None) or [])]
|
|
36
|
+
|
|
37
|
+
def path_matches(path: str, patterns: list[str]) -> bool:
|
|
38
|
+
return any(path == p or path.startswith(p + "/") for p in patterns)
|
|
39
|
+
|
|
40
|
+
def regex_matches(path: str, patterns: list[re.Pattern]) -> bool:
|
|
41
|
+
return any(p.search(path) for p in patterns)
|
|
42
|
+
|
|
43
|
+
kept = []
|
|
44
|
+
for tool in tools:
|
|
45
|
+
meta = tool["_meta"]
|
|
46
|
+
read_only_dropped = args.read_only and meta["method"] != "GET"
|
|
47
|
+
if read_only_dropped and not regex_matches(meta["path"], allow):
|
|
48
|
+
continue
|
|
49
|
+
if args.tag and args.tag not in (meta.get("tags") or []):
|
|
50
|
+
continue
|
|
51
|
+
if include and not path_matches(meta["path"], include):
|
|
52
|
+
continue
|
|
53
|
+
if exclude and path_matches(meta["path"], exclude):
|
|
54
|
+
continue
|
|
55
|
+
if regex_matches(meta["path"], deny):
|
|
56
|
+
continue
|
|
57
|
+
kept.append(tool)
|
|
58
|
+
return kept
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _base_url(spec: dict, override: str | None) -> str:
|
|
62
|
+
if override:
|
|
63
|
+
return override
|
|
64
|
+
servers = spec_servers(spec)
|
|
65
|
+
if servers:
|
|
66
|
+
return servers[0]
|
|
67
|
+
_fail(
|
|
68
|
+
"no base URL: the spec declares no servers and --base-url was not given"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> None:
|
|
73
|
+
parser = argparse.ArgumentParser(
|
|
74
|
+
prog="mcpify",
|
|
75
|
+
description=(
|
|
76
|
+
"Turn any OpenAPI REST API into an MCP server so AI agents "
|
|
77
|
+
"(Claude Code, Cursor, ...) can call it β zero dependencies."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
81
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
82
|
+
|
|
83
|
+
p_list = sub.add_parser("list", help="preview the tools that would be generated")
|
|
84
|
+
p_list.add_argument("spec", help="path or URL of an OpenAPI document")
|
|
85
|
+
p_list.add_argument("--tag", help="only operations with this tag")
|
|
86
|
+
p_list.add_argument("--include", action="append", help="only these path prefixes (repeatable)")
|
|
87
|
+
p_list.add_argument("--exclude", action="append", help="skip these path prefixes (repeatable)")
|
|
88
|
+
p_list.add_argument("--read-only", action="store_true", help="only GET operations")
|
|
89
|
+
p_list.add_argument("--allow", action="append", metavar="REGEX",
|
|
90
|
+
help="re-include operations dropped by --read-only (repeatable)")
|
|
91
|
+
p_list.add_argument("--deny", action="append", metavar="REGEX",
|
|
92
|
+
help="never expose matching paths, overrides --allow (repeatable)")
|
|
93
|
+
p_list.add_argument("--json", action="store_true", help="machine-readable output")
|
|
94
|
+
|
|
95
|
+
p_serve = sub.add_parser("serve", help="start the MCP stdio server")
|
|
96
|
+
p_serve.add_argument("spec", help="path or URL of an OpenAPI document")
|
|
97
|
+
p_serve.add_argument("--base-url", help="API base URL (default: spec servers[0])")
|
|
98
|
+
p_serve.add_argument("--name", default="mcpify", help="server name reported to clients")
|
|
99
|
+
p_serve.add_argument("--auth-env", help="env variable holding the API credential")
|
|
100
|
+
p_serve.add_argument(
|
|
101
|
+
"--auth-style",
|
|
102
|
+
choices=("bearer", "header", "query"),
|
|
103
|
+
default="bearer",
|
|
104
|
+
help="how to send the credential (default: bearer)",
|
|
105
|
+
)
|
|
106
|
+
p_serve.add_argument("--auth-name", help="header or query parameter name for non-bearer auth")
|
|
107
|
+
p_serve.add_argument("--timeout", type=float, default=30.0, help="HTTP timeout seconds")
|
|
108
|
+
p_serve.add_argument("--tag", help="only operations with this tag")
|
|
109
|
+
p_serve.add_argument("--include", action="append", help="only these path prefixes (repeatable)")
|
|
110
|
+
p_serve.add_argument("--exclude", action="append", help="skip these path prefixes (repeatable)")
|
|
111
|
+
p_serve.add_argument("--read-only", action="store_true", help="expose only GET operations")
|
|
112
|
+
p_serve.add_argument("--allow", action="append", metavar="REGEX",
|
|
113
|
+
help="re-include operations dropped by --read-only (repeatable)")
|
|
114
|
+
p_serve.add_argument("--deny", action="append", metavar="REGEX",
|
|
115
|
+
help="never expose matching paths, overrides --allow (repeatable)")
|
|
116
|
+
|
|
117
|
+
p_doctor = sub.add_parser("doctor", help="inspect a spec and report problems")
|
|
118
|
+
p_doctor.add_argument("spec", help="path or URL of an OpenAPI document")
|
|
119
|
+
|
|
120
|
+
args = parser.parse_args(argv)
|
|
121
|
+
|
|
122
|
+
if args.command in ("list", "serve"):
|
|
123
|
+
try:
|
|
124
|
+
spec = load_spec(args.spec)
|
|
125
|
+
except SpecError as err:
|
|
126
|
+
_fail(str(err))
|
|
127
|
+
all_tools = spec_to_tools(spec)
|
|
128
|
+
tools = filter_tools(all_tools, args)
|
|
129
|
+
if not tools:
|
|
130
|
+
_fail("no operations matched (the API would expose 0 tools)")
|
|
131
|
+
|
|
132
|
+
if args.command == "list":
|
|
133
|
+
if args.json:
|
|
134
|
+
print(
|
|
135
|
+
json.dumps(
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
"name": t["name"],
|
|
139
|
+
"method": t["_meta"]["method"],
|
|
140
|
+
"path": t["_meta"]["path"],
|
|
141
|
+
"description": t["description"],
|
|
142
|
+
}
|
|
143
|
+
for t in tools
|
|
144
|
+
],
|
|
145
|
+
ensure_ascii=False,
|
|
146
|
+
indent=2,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
def bold(s: str) -> str:
|
|
151
|
+
return f"\033[1m{s}\033[0m" if USE_COLOR else s
|
|
152
|
+
def dim(s: str) -> str:
|
|
153
|
+
return f"\033[2m{s}\033[0m" if USE_COLOR else s
|
|
154
|
+
def green(s: str) -> str:
|
|
155
|
+
return f"\033[32m{s}\033[0m" if USE_COLOR else s
|
|
156
|
+
def cyan(s: str) -> str:
|
|
157
|
+
return f"\033[36m{s}\033[0m" if USE_COLOR else s
|
|
158
|
+
print(bold(f"mcpify: {len(tools)} tools from {args.spec}"))
|
|
159
|
+
print(dim("β" * 78))
|
|
160
|
+
for tool in tools:
|
|
161
|
+
meta = tool["_meta"]
|
|
162
|
+
body = dim(" +body") if meta["has_body"] else ""
|
|
163
|
+
desc = tool["description"]
|
|
164
|
+
desc = desc[len(meta["method"]) + 3:] if desc.startswith("[") else desc
|
|
165
|
+
print(
|
|
166
|
+
f" {cyan(tool['name']):36} {green(meta['method']):8} "
|
|
167
|
+
f"{meta['path']}{body}"
|
|
168
|
+
)
|
|
169
|
+
if desc:
|
|
170
|
+
print(f" {'':36} {dim(desc[:90])}")
|
|
171
|
+
print(dim("β" * 78))
|
|
172
|
+
print(dim(f"serve it: mcpify serve {args.spec}"))
|
|
173
|
+
|
|
174
|
+
elif args.command == "serve":
|
|
175
|
+
if not args.base_url and not spec_servers(spec):
|
|
176
|
+
_fail(
|
|
177
|
+
"no base URL: the spec declares no servers and --base-url was not given"
|
|
178
|
+
)
|
|
179
|
+
auth = None
|
|
180
|
+
if args.auth_env:
|
|
181
|
+
auth = AuthConfig(args.auth_env, args.auth_style, args.auth_name)
|
|
182
|
+
from .api_server import ApiServer
|
|
183
|
+
|
|
184
|
+
base = args.base_url or spec_servers(spec)[0]
|
|
185
|
+
server = ApiServer(
|
|
186
|
+
spec,
|
|
187
|
+
base,
|
|
188
|
+
server_name=args.name,
|
|
189
|
+
auth=auth,
|
|
190
|
+
timeout=args.timeout,
|
|
191
|
+
)
|
|
192
|
+
server.tools = tools
|
|
193
|
+
server.by_name = {tool["name"]: tool for tool in tools}
|
|
194
|
+
print(
|
|
195
|
+
f"mcpify: serving {len(tools)} tools from {args.spec} -> {base}",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
server.serve()
|
|
199
|
+
|
|
200
|
+
elif args.command == "doctor":
|
|
201
|
+
try:
|
|
202
|
+
spec = load_spec(args.spec)
|
|
203
|
+
except SpecError as err:
|
|
204
|
+
_fail(str(err))
|
|
205
|
+
total = 0
|
|
206
|
+
missing_id = 0
|
|
207
|
+
no_summary = 0
|
|
208
|
+
for _method, _path, operation in iter_operations(spec):
|
|
209
|
+
total += 1
|
|
210
|
+
if not operation.get("operationId"):
|
|
211
|
+
missing_id += 1
|
|
212
|
+
if not (operation.get("summary") or operation.get("description")):
|
|
213
|
+
no_summary += 1
|
|
214
|
+
servers = spec_servers(spec)
|
|
215
|
+
variabled = [s for s in servers if "{" in s]
|
|
216
|
+
def ok(s: str) -> str:
|
|
217
|
+
return f"\033[32m{s}\033[0m" if USE_COLOR else s
|
|
218
|
+
|
|
219
|
+
def warn(s: str) -> str:
|
|
220
|
+
return f"\033[33m{s}\033[0m" if USE_COLOR else s
|
|
221
|
+
|
|
222
|
+
print(f"openapi: {spec.get('openapi') or spec.get('swagger')}")
|
|
223
|
+
print(f"title: {spec.get('info', {}).get('title', '(untitled)')}")
|
|
224
|
+
print(f"paths: {len(spec.get('paths', {}))}")
|
|
225
|
+
print(f"tools: {len(spec_to_tools(spec))} operations")
|
|
226
|
+
print(f"servers: {', '.join(servers) or warn('none declared (pass --base-url)')}")
|
|
227
|
+
if missing_id:
|
|
228
|
+
print(warn(f"warning: {missing_id}/{total} operations have no operationId (names fall back to method_path)"))
|
|
229
|
+
if no_summary:
|
|
230
|
+
print(warn(f"warning: {no_summary}/{total} operations have no summary (agents see no description)"))
|
|
231
|
+
if variabled:
|
|
232
|
+
print(warn(f"warning: server URL(s) contain variables: {', '.join(variabled)} β pass --base-url"))
|
|
233
|
+
if not missing_id and not no_summary:
|
|
234
|
+
print(ok("all operations carry operationId and summary β agent-friendly β"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|
mcpify/http_client.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Execute built HTTP requests with urllib; never raises on HTTP >= 400."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def execute(request: dict, timeout: float = 30.0) -> dict:
|
|
12
|
+
"""Perform the request and return {status, body, json}.
|
|
13
|
+
|
|
14
|
+
HTTP errors (4xx/5xx) are returned as results instead of raising, so
|
|
15
|
+
the agent can see API error payloads and react to them.
|
|
16
|
+
"""
|
|
17
|
+
data = request.get("body")
|
|
18
|
+
req = urllib.request.Request(
|
|
19
|
+
request["url"],
|
|
20
|
+
data=data,
|
|
21
|
+
headers=request["headers"],
|
|
22
|
+
method=request["method"],
|
|
23
|
+
)
|
|
24
|
+
try:
|
|
25
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
26
|
+
status = response.status
|
|
27
|
+
raw = response.read().decode("utf-8", "replace")
|
|
28
|
+
except urllib.error.HTTPError as err:
|
|
29
|
+
status = err.code
|
|
30
|
+
raw = err.read().decode("utf-8", "replace")
|
|
31
|
+
except urllib.error.URLError as err:
|
|
32
|
+
return {
|
|
33
|
+
"status": 0,
|
|
34
|
+
"body": f"connection failed: {err.reason}",
|
|
35
|
+
"json": None,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parsed = None
|
|
39
|
+
with contextlib.suppress(json.JSONDecodeError, ValueError):
|
|
40
|
+
parsed = json.loads(raw)
|
|
41
|
+
return {"status": status, "body": raw, "json": parsed}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_result(result: dict) -> tuple[str, bool]:
|
|
45
|
+
"""Format an execute() result for an MCP tool response: (text, is_error)."""
|
|
46
|
+
body = result["body"]
|
|
47
|
+
if result["json"] is not None:
|
|
48
|
+
body = json.dumps(result["json"], ensure_ascii=False, indent=2)
|
|
49
|
+
is_error = result["status"] == 0 or result["status"] >= 400
|
|
50
|
+
if is_error:
|
|
51
|
+
return f"HTTP {result['status']}\n{body}", is_error
|
|
52
|
+
return body, is_error
|
mcpify/spec.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""OpenAPI specification loading, $ref resolution, and operation walking.
|
|
2
|
+
|
|
3
|
+
Supports JSON natively (zero dependencies). YAML specs work when PyYAML
|
|
4
|
+
is installed (``pip install 'mcpify[yaml]'``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
HTTP_METHODS = ("get", "put", "post", "delete", "patch", "options", "head")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpecError(ValueError):
|
|
19
|
+
"""Raised when an OpenAPI document cannot be loaded or is invalid."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_spec(source: str) -> dict:
|
|
23
|
+
"""Load an OpenAPI document from a file path or an http(s) URL.
|
|
24
|
+
|
|
25
|
+
JSON is always supported; YAML requires PyYAML.
|
|
26
|
+
"""
|
|
27
|
+
text: str
|
|
28
|
+
if source.startswith(("http://", "https://")):
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(source, timeout=15) as response:
|
|
31
|
+
text = response.read().decode("utf-8")
|
|
32
|
+
except Exception as err:
|
|
33
|
+
raise SpecError(f"could not fetch '{source}': {err}") from err
|
|
34
|
+
else:
|
|
35
|
+
path = Path(source)
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
raise SpecError(f"file not found: {source}")
|
|
38
|
+
text = path.read_text(encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
# Try JSON first (the common case), fall back to YAML when available.
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(text)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
try:
|
|
45
|
+
import yaml
|
|
46
|
+
except ImportError:
|
|
47
|
+
raise SpecError(
|
|
48
|
+
f"'{source}' is not valid JSON. For YAML specs install: "
|
|
49
|
+
"pip install 'mcpify[yaml]'"
|
|
50
|
+
) from None
|
|
51
|
+
try:
|
|
52
|
+
data = yaml.safe_load(text)
|
|
53
|
+
except Exception as err:
|
|
54
|
+
raise SpecError(f"could not parse '{source}': {err}") from err
|
|
55
|
+
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
raise SpecError("specification root must be an object")
|
|
58
|
+
if "openapi" not in data and "swagger" not in data:
|
|
59
|
+
raise SpecError(
|
|
60
|
+
"not an OpenAPI document (missing 'openapi' or 'swagger' version field)"
|
|
61
|
+
)
|
|
62
|
+
if not isinstance(data.get("paths"), dict):
|
|
63
|
+
raise SpecError("specification has no 'paths' object")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_ref(spec: dict, ref: str) -> Any:
|
|
68
|
+
"""Resolve a local reference like '#/components/schemas/Pet'."""
|
|
69
|
+
if not ref.startswith("#/"):
|
|
70
|
+
raise SpecError(
|
|
71
|
+
f"only local references are supported (got '{ref}'); "
|
|
72
|
+
"inline or bundle external documents first"
|
|
73
|
+
)
|
|
74
|
+
node = spec
|
|
75
|
+
for part in ref[2:].split("/"):
|
|
76
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
77
|
+
try:
|
|
78
|
+
node = node[part]
|
|
79
|
+
except (KeyError, TypeError):
|
|
80
|
+
raise SpecError(f"unresolvable reference: '{ref}'") from None
|
|
81
|
+
return node
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_schema(schema: Any, spec: dict, depth: int = 0) -> Any:
|
|
85
|
+
"""Deeply resolve internal $ref pointers inside a schema."""
|
|
86
|
+
if depth > 20:
|
|
87
|
+
raise SpecError("circular $ref chain too deep (max 20)")
|
|
88
|
+
if isinstance(schema, list):
|
|
89
|
+
return [resolve_schema(item, spec, depth) for item in schema]
|
|
90
|
+
if not isinstance(schema, dict):
|
|
91
|
+
return schema
|
|
92
|
+
|
|
93
|
+
if "$ref" in schema:
|
|
94
|
+
target = resolve_ref(spec, schema["$ref"])
|
|
95
|
+
return resolve_schema(target, spec, depth + 1)
|
|
96
|
+
|
|
97
|
+
resolved = {}
|
|
98
|
+
for key, value in schema.items():
|
|
99
|
+
if key == "items" and isinstance(value, dict):
|
|
100
|
+
resolved[key] = resolve_schema(value, spec, depth + 1)
|
|
101
|
+
elif key == "properties" and isinstance(value, dict):
|
|
102
|
+
resolved[key] = {
|
|
103
|
+
name: resolve_schema(sub, spec, depth + 1) for name, sub in value.items()
|
|
104
|
+
}
|
|
105
|
+
elif key in ("anyOf", "oneOf", "allOf") and isinstance(value, list):
|
|
106
|
+
resolved[key] = [resolve_schema(sub, spec, depth + 1) for sub in value]
|
|
107
|
+
else:
|
|
108
|
+
resolved[key] = value
|
|
109
|
+
return resolved
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def iter_operations(spec: dict) -> Iterator[tuple[str, str, dict]]:
|
|
113
|
+
"""Yield (method, path, operation) for every operation in the document."""
|
|
114
|
+
for path, path_item in spec.get("paths", {}).items():
|
|
115
|
+
if not isinstance(path_item, dict):
|
|
116
|
+
continue
|
|
117
|
+
for method in HTTP_METHODS:
|
|
118
|
+
operation = path_item.get(method)
|
|
119
|
+
if isinstance(operation, dict):
|
|
120
|
+
yield method.upper(), path, operation
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def spec_servers(spec: dict) -> list[str]:
|
|
124
|
+
"""Return the declared server URLs (may be empty)."""
|
|
125
|
+
servers = spec.get("servers") or []
|
|
126
|
+
return [s.get("url", "") for s in servers if isinstance(s, dict)]
|
mcpify/tools.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Translate OpenAPI operations into MCP tools, and arguments into requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .spec import resolve_schema
|
|
10
|
+
|
|
11
|
+
# Body arguments are exposed under this property name.
|
|
12
|
+
BODY_ARG = "body"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def slugify(*parts: str) -> str:
|
|
16
|
+
text = "_".join(parts).lower()
|
|
17
|
+
return re.sub(r"[^a-z0-9]+", "_", text).strip("_") or "call"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def operation_id(method: str, path: str, operation: dict) -> str:
|
|
21
|
+
"""Prefer the spec's operationId, fall back to method_path."""
|
|
22
|
+
declared = str(operation.get("operationId", "")).strip()
|
|
23
|
+
if declared:
|
|
24
|
+
return slugify(declared)
|
|
25
|
+
template = re.sub(r"[{}]", "", path)
|
|
26
|
+
return slugify(method, template)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_description(method: str, path: str, operation: dict) -> str:
|
|
30
|
+
text = str(operation.get("summary") or operation.get("description") or "").strip()
|
|
31
|
+
if text:
|
|
32
|
+
return f"[{method}] {text}"
|
|
33
|
+
return f"[{method}] Call {path}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_parameters(operation: dict, path_item: dict, spec: dict) -> list[dict]:
|
|
37
|
+
"""Collect path/query/header parameters from the operation and its path item."""
|
|
38
|
+
merged: dict[tuple, dict] = {}
|
|
39
|
+
|
|
40
|
+
def add(params: list[dict]) -> None:
|
|
41
|
+
for param in params:
|
|
42
|
+
if not isinstance(param, dict):
|
|
43
|
+
continue
|
|
44
|
+
if "$ref" in param:
|
|
45
|
+
from .spec import resolve_ref
|
|
46
|
+
|
|
47
|
+
param = resolve_ref(spec, param["$ref"])
|
|
48
|
+
key = (param.get("in"), param.get("name"))
|
|
49
|
+
merged[key] = param
|
|
50
|
+
|
|
51
|
+
add(path_item.get("parameters") or [])
|
|
52
|
+
add(operation.get("parameters") or [])
|
|
53
|
+
return list(merged.values())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_input_schema(
|
|
57
|
+
method: str,
|
|
58
|
+
operation: dict,
|
|
59
|
+
parameters: list[dict],
|
|
60
|
+
request_schema: dict | None,
|
|
61
|
+
) -> dict:
|
|
62
|
+
"""Build the JSON Schema for an MCP tool from parameters + request body."""
|
|
63
|
+
properties: dict = {}
|
|
64
|
+
required: list[str] = []
|
|
65
|
+
|
|
66
|
+
for param in parameters:
|
|
67
|
+
location = param.get("in")
|
|
68
|
+
if location not in ("path", "query", "header"):
|
|
69
|
+
continue
|
|
70
|
+
name = str(param.get("name", ""))
|
|
71
|
+
if not name:
|
|
72
|
+
continue
|
|
73
|
+
schema = resolve_schema(param.get("schema") or {}, {}) if param.get("schema") else {"type": "string"}
|
|
74
|
+
entry = {"type": schema.get("type", "string"), "description": str(param.get("description", ""))}
|
|
75
|
+
if schema.get("enum"):
|
|
76
|
+
entry["enum"] = schema["enum"]
|
|
77
|
+
# never advertise a header param for Authorization β auth is managed by flags
|
|
78
|
+
if location == "header" and name.lower() == "authorization":
|
|
79
|
+
continue
|
|
80
|
+
# path/query params are plain names; header params are namespaced
|
|
81
|
+
key = name if location in ("path", "query") else f"header:{name}"
|
|
82
|
+
properties[key] = entry
|
|
83
|
+
if param.get("required"):
|
|
84
|
+
required.append(key)
|
|
85
|
+
|
|
86
|
+
if request_schema is not None and method not in ("GET", "HEAD", "DELETE"):
|
|
87
|
+
properties[BODY_ARG] = {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"description": "JSON request body",
|
|
90
|
+
**({"properties": request_schema.get("properties", {})} if request_schema.get("properties") else {}),
|
|
91
|
+
**({"required": request_schema["required"]} if request_schema.get("required") else {}),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {"type": "object", "properties": properties, "required": required}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def operation_to_tool(method: str, path: str, operation: dict, path_item: dict, spec: dict, taken: set) -> dict:
|
|
98
|
+
"""Return an MCP tool descriptor for one OpenAPI operation."""
|
|
99
|
+
parameters = extract_parameters(operation, path_item, spec)
|
|
100
|
+
|
|
101
|
+
request_schema = None
|
|
102
|
+
body = operation.get("requestBody")
|
|
103
|
+
if isinstance(body, dict):
|
|
104
|
+
content = body.get("content") or {}
|
|
105
|
+
json_media = content.get("application/json")
|
|
106
|
+
if isinstance(json_media, dict) and json_media.get("schema"):
|
|
107
|
+
request_schema = resolve_schema(json_media["schema"], spec)
|
|
108
|
+
|
|
109
|
+
name = operation_id(method, path, operation)
|
|
110
|
+
base = name
|
|
111
|
+
counter = 2
|
|
112
|
+
while name in taken:
|
|
113
|
+
name = f"{base}_{counter}"
|
|
114
|
+
counter += 1
|
|
115
|
+
taken.add(name)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"name": name,
|
|
119
|
+
"description": build_description(method, path, operation),
|
|
120
|
+
"inputSchema": build_input_schema(method.upper(), operation, parameters, request_schema),
|
|
121
|
+
"_meta": {
|
|
122
|
+
"method": method.upper(),
|
|
123
|
+
"path": path,
|
|
124
|
+
"parameters": parameters,
|
|
125
|
+
"has_body": request_schema is not None,
|
|
126
|
+
"tags": list(operation.get("tags") or []),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def spec_to_tools(spec: dict) -> list[dict]:
|
|
132
|
+
"""Convert every operation in the spec into MCP tool descriptors."""
|
|
133
|
+
from .spec import iter_operations
|
|
134
|
+
|
|
135
|
+
tools: list[dict] = []
|
|
136
|
+
taken: set = set()
|
|
137
|
+
for method, path, operation in iter_operations(spec):
|
|
138
|
+
path_item = spec["paths"][path]
|
|
139
|
+
tools.append(operation_to_tool(method, path, operation, path_item, spec, taken))
|
|
140
|
+
return tools
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# request building
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
class RequestError(ValueError):
|
|
148
|
+
"""Raised when arguments cannot form a valid HTTP request."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_request(
|
|
152
|
+
base_url: str,
|
|
153
|
+
meta: dict,
|
|
154
|
+
arguments: dict,
|
|
155
|
+
auth: AuthConfig | None = None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
"""Turn tool arguments into a concrete HTTP request (url/headers/body)."""
|
|
158
|
+
|
|
159
|
+
method = meta["method"]
|
|
160
|
+
path_template = meta["path"]
|
|
161
|
+
used: set = set()
|
|
162
|
+
|
|
163
|
+
# path parameters
|
|
164
|
+
def substitute(match: re.Match) -> str:
|
|
165
|
+
name = match.group(1)
|
|
166
|
+
arg = arguments.get(name)
|
|
167
|
+
if arg is None or arg == "":
|
|
168
|
+
raise RequestError(f"missing required path parameter '{name}'")
|
|
169
|
+
used.add(name)
|
|
170
|
+
return quote(str(arg), safe="")
|
|
171
|
+
|
|
172
|
+
path = re.sub(r"\{([^{}]+)\}", substitute, path_template)
|
|
173
|
+
if "{" in path or "}" in path:
|
|
174
|
+
raise RequestError(f"unfilled path parameter in '{path}'")
|
|
175
|
+
|
|
176
|
+
# query parameters
|
|
177
|
+
query_pairs: list[tuple[str, str]] = []
|
|
178
|
+
for param in meta["parameters"]:
|
|
179
|
+
if param.get("in") != "query":
|
|
180
|
+
continue
|
|
181
|
+
name = str(param.get("name", ""))
|
|
182
|
+
if name in arguments and arguments[name] not in (None, ""):
|
|
183
|
+
query_pairs.append((name, str(arguments[name])))
|
|
184
|
+
used.add(name)
|
|
185
|
+
|
|
186
|
+
# header parameters
|
|
187
|
+
headers = {"Accept": "application/json"}
|
|
188
|
+
for param in meta["parameters"]:
|
|
189
|
+
if param.get("in") != "header":
|
|
190
|
+
continue
|
|
191
|
+
name = str(param.get("name", ""))
|
|
192
|
+
key = f"header:{name}"
|
|
193
|
+
if key in arguments and arguments[key] not in (None, ""):
|
|
194
|
+
headers[name] = str(arguments[key])
|
|
195
|
+
used.add(key)
|
|
196
|
+
|
|
197
|
+
# body
|
|
198
|
+
body_bytes = None
|
|
199
|
+
if meta.get("has_body"):
|
|
200
|
+
body = arguments.get(BODY_ARG)
|
|
201
|
+
if body is None:
|
|
202
|
+
raise RequestError(f"missing required argument '{BODY_ARG}' (JSON request body)")
|
|
203
|
+
if not isinstance(body, dict):
|
|
204
|
+
raise RequestError(f"'{BODY_ARG}' must be a JSON object")
|
|
205
|
+
body_bytes = json.dumps(body).encode("utf-8")
|
|
206
|
+
headers["Content-Type"] = "application/json"
|
|
207
|
+
used.add(BODY_ARG)
|
|
208
|
+
|
|
209
|
+
unknown = sorted(set(arguments) - used)
|
|
210
|
+
if unknown:
|
|
211
|
+
raise RequestError(f"unknown argument(s): {', '.join(unknown)}")
|
|
212
|
+
|
|
213
|
+
url = base_url.rstrip("/") + path
|
|
214
|
+
if query_pairs:
|
|
215
|
+
from urllib.parse import urlencode
|
|
216
|
+
|
|
217
|
+
url += "?" + urlencode(query_pairs)
|
|
218
|
+
|
|
219
|
+
if auth is not None:
|
|
220
|
+
headers.update(auth.headers())
|
|
221
|
+
|
|
222
|
+
return {"method": method, "url": url, "headers": headers, "body": body_bytes}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class AuthConfig:
|
|
226
|
+
"""Authentication injected into outgoing requests from an env variable."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, env_var: str, style: str = "bearer", name: str | None = None):
|
|
229
|
+
self.env_var = env_var
|
|
230
|
+
self.style = style
|
|
231
|
+
self.name = name
|
|
232
|
+
|
|
233
|
+
def headers(self) -> dict:
|
|
234
|
+
import os
|
|
235
|
+
|
|
236
|
+
value = os.environ.get(self.env_var)
|
|
237
|
+
if not value:
|
|
238
|
+
raise RequestError(
|
|
239
|
+
f"environment variable '{self.env_var}' is not set "
|
|
240
|
+
"(required for API authentication)"
|
|
241
|
+
)
|
|
242
|
+
if self.style == "bearer":
|
|
243
|
+
return {"Authorization": f"Bearer {value}"}
|
|
244
|
+
if self.style == "header":
|
|
245
|
+
return {(self.name or "X-API-Key"): value}
|
|
246
|
+
return {} # query style is applied at URL build time
|
|
247
|
+
|
|
248
|
+
def apply_query(self, url: str) -> str:
|
|
249
|
+
if self.style != "query":
|
|
250
|
+
return url
|
|
251
|
+
import os
|
|
252
|
+
|
|
253
|
+
value = os.environ.get(self.env_var)
|
|
254
|
+
if not value:
|
|
255
|
+
raise RequestError(f"environment variable '{self.env_var}' is not set")
|
|
256
|
+
separator = "&" if "?" in url else "?"
|
|
257
|
+
return f"{url}{separator}{self.name or 'api_key'}={quote(value)}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def describe_tools(tools: list[dict]) -> str:
|
|
261
|
+
"""One-line-per-tool summary used by `mcpify list`."""
|
|
262
|
+
lines = []
|
|
263
|
+
for tool in tools:
|
|
264
|
+
meta = tool["_meta"]
|
|
265
|
+
body = " +body" if meta["has_body"] else ""
|
|
266
|
+
lines.append(
|
|
267
|
+
f"{tool['name']:34} {meta['method']:7} {meta['path']}{body} {tool['description'][len(meta['method']) + 3:]}"
|
|
268
|
+
)
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def input_schema_json(schema: dict) -> str:
|
|
273
|
+
"""Compact, stable JSON dump for schemas (without private keys)."""
|
|
274
|
+
return json.dumps({k: v for k, v in schema.items() if not k.startswith("_")}, ensure_ascii=False)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcpify-openapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Turn any OpenAPI REST API into an MCP server so AI agents can call it β zero dependencies
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: mcp,model-context-protocol,openapi,swagger,ai-agents,claude,rest,api,cli
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: yaml
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.3; extra == "yaml"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.16.4; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=2.3.1; extra == "dev"
|
|
23
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.3; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# π mcpify
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
π English | [TΓΌrkΓ§e](README.tr.md)
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="docs/demo.gif" alt="mcpify in action β listing and serving OpenAPI endpoints as MCP tools" width="720">
|
|
36
|
+
</p>
|
|
37
|
+

|
|
38
|
+
[](https://github.com/furkan708/mcpify/actions/workflows/ci.yml)  
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
**Turn any OpenAPI REST API into an [MCP](https://modelcontextprotocol.io) server** β so Claude Code, Cursor, and every other MCP client can call your API directly. One command. Zero dependencies.
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
Your company has a REST API. Your AI agent needs to call it. Until now that
|
|
46
|
+
meant hand-writing a custom MCP server for every API. With mcpify:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
mcpify serve https://your-company.com/openapi.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's it β every endpoint just became a tool your AI agent can discover, understand, and call.
|
|
53
|
+
|
|
54
|
+
π **Deep docs:** [Usage guide](docs/USAGE.md) β auth patterns, scoping, Docker, troubleshooting Β· [Architecture](docs/ARCHITECTURE.md) Β· [Contributing](CONTRIBUTING.md) Β· [Changelog](CHANGELOG.md) Β· [Security](SECURITY.md)
|
|
55
|
+
|
|
56
|
+
## β¨ Why you'll like it
|
|
57
|
+
|
|
58
|
+
- β‘ **60 seconds to working** β point it at any OpenAPI 3.x spec (file or URL)
|
|
59
|
+
- π **Credentials never touch the spec or the model** β pulled from your
|
|
60
|
+
environment at call time (`--auth-env`), sent as `Authorization: Bearer`,
|
|
61
|
+
a custom header, or a query parameter
|
|
62
|
+
- π§° **Every operation becomes a first-class MCP tool** β input schemas are
|
|
63
|
+
generated from `parameters` + `requestBody`, internal `$ref`s are resolved
|
|
64
|
+
- ποΈ **Scope it down** β `--read-only` (GET only), `--tag payments`,
|
|
65
|
+
`--include /v1/orders`, `--exclude /admin`, plus a policy layer for real-world
|
|
66
|
+
APIs: `--deny REGEX` hides mutating GETs, `--allow REGEX` re-includes
|
|
67
|
+
read-style POST endpoints. Deny always wins.
|
|
68
|
+
- π©Ί **`mcpify doctor`** β tells you if your spec is agent-friendly before you ship
|
|
69
|
+
- πͺΆ **Zero dependencies** β one pure-Python file tree; YAML specs need an
|
|
70
|
+
optional `pip install 'mcpify[yaml]'`
|
|
71
|
+
- π§ͺ **53 tests** including a full end-to-end suite against a real local HTTP API
|
|
72
|
+
|
|
73
|
+
## π Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone https://github.com/furkan708/mcpify.git
|
|
77
|
+
cd mcpify && pip install .
|
|
78
|
+
|
|
79
|
+
# 1. preview the tools that will be generated
|
|
80
|
+
mcpify list examples/petstore.json
|
|
81
|
+
|
|
82
|
+
# 2. validate the spec is agent-friendly
|
|
83
|
+
mcpify doctor examples/petstore.json
|
|
84
|
+
|
|
85
|
+
# 3. serve it over MCP
|
|
86
|
+
mcpify serve examples/petstore.json --base-url https://petstore.example.com/v1
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### With authentication
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Bearer token read from the environment (never hardcoded)
|
|
93
|
+
export PETSTORE_KEY="sk-..."
|
|
94
|
+
mcpify serve petstore.json \
|
|
95
|
+
--base-url https://petstore.example.com/v1 \
|
|
96
|
+
--auth-env PETSTORE_KEY \
|
|
97
|
+
--auth-style bearer \
|
|
98
|
+
--read-only
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Flag | Meaning |
|
|
102
|
+
| ---- | ------- |
|
|
103
|
+
| `--auth-env VAR` | environment variable holding the credential |
|
|
104
|
+
| `--auth-style bearer\|header\|query` | how it is sent |
|
|
105
|
+
| `--auth-name NAME` | header / query name for non-bearer styles (e.g. `X-API-Key`) |
|
|
106
|
+
|
|
107
|
+
## π€ Plug it into your agent
|
|
108
|
+
|
|
109
|
+
**Claude Code:**
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
claude mcp add my-api -- mcpify serve openapi.json --read-only
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Claude Desktop / Cursor / any MCP client** (`claude_desktop_config.json`):
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"mcpServers": {
|
|
120
|
+
"petstore": {
|
|
121
|
+
"command": "mcpify",
|
|
122
|
+
"args": ["serve", "~/specs/petstore.json", "--auth-env", "PETSTORE_KEY"]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Now ask your agent: *"list the pets, then create one named Milo"* β it discovers `list_pets` and `create_pet`, fills the arguments, and performs real HTTP calls.
|
|
129
|
+
|
|
130
|
+
## π How operations become tools
|
|
131
|
+
|
|
132
|
+
| OpenAPI | mcpify |
|
|
133
|
+
| ------- | ------ |
|
|
134
|
+
| `operationId` | tool name (sanitized; falls back to `method_path`) |
|
|
135
|
+
| `summary` / `description` | tool description the agent reads |
|
|
136
|
+
| `parameters` (path/query/header) | individual typed arguments with enums |
|
|
137
|
+
| `requestBody` (JSON) | a `body` object argument |
|
|
138
|
+
| `$ref` pointers | resolved inline (components β real schemas) |
|
|
139
|
+
| `servers[0].url` | default base URL (override: `--base-url`) |
|
|
140
|
+
|
|
141
|
+
The agent only ever sees the tool list and your API's JSON responses β
|
|
142
|
+
mcpify adds no middleware, caches nothing, and sends credentials nowhere
|
|
143
|
+
except your API.
|
|
144
|
+
|
|
145
|
+
## π©Ί Doctor
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
$ mcpify doctor my-api.json
|
|
149
|
+
openapi: 3.0.3
|
|
150
|
+
title: Acme API
|
|
151
|
+
paths: 23
|
|
152
|
+
tools: 41 operations
|
|
153
|
+
servers: https://api.acme.com
|
|
154
|
+
warning: 12/41 operations have no operationId (names fall back to method_path)
|
|
155
|
+
warning: 30/41 operations have no summary (agents see no description)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## π CLI reference
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
mcpify list <spec> [--tag T] [--include P] [--exclude P] [--read-only] [--json]
|
|
162
|
+
mcpify serve <spec> [--base-url URL] [--name N] [--auth-env VAR]
|
|
163
|
+
[--auth-style bearer|header|query] [--auth-name NAME]
|
|
164
|
+
[--timeout S] [--read-only] [--tag T] [--include P] [--exclude P]
|
|
165
|
+
mcpify doctor <spec>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Notes & limitations
|
|
169
|
+
|
|
170
|
+
- JSON specs work out of the box; YAML specs need `pip install 'mcpify[yaml]'`
|
|
171
|
+
- Only local `$ref` pointers are resolved (bundle external docs first β most tools do anyway)
|
|
172
|
+
- Request bodies are exposed as a single `body` object argument β predictable over clever
|
|
173
|
+
- Spec versions: OpenAPI 3.x and Swagger 2.x roots are accepted; 3.x is the happy path
|
|
174
|
+
|
|
175
|
+
## π§ͺ Tests
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
pip install pytest pyyaml
|
|
179
|
+
pytest -v
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The e2e suite boots a real local HTTP API and drives the full MCP protocol
|
|
183
|
+
over stdio β initialize β tools/list β tools/call β and asserts on the HTTP
|
|
184
|
+
requests that hit the wire.
|
|
185
|
+
|
|
186
|
+
## ποΈ Project Structure
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
mcpify/
|
|
190
|
+
βββ mcpify/
|
|
191
|
+
β βββ spec.py # OpenAPI loading, $ref resolution, operation walking
|
|
192
|
+
β βββ tools.py # operation -> MCP tool, argument -> HTTP request
|
|
193
|
+
β βββ http_client.py # execution (urllib, HTTP errors become tool results)
|
|
194
|
+
β βββ api_server.py # MCP stdio server (JSON-RPC 2.0)
|
|
195
|
+
β βββ cli.py # list / serve / doctor
|
|
196
|
+
βββ examples/petstore.json
|
|
197
|
+
βββ tests/
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## πΊοΈ Roadmap
|
|
201
|
+
|
|
202
|
+
- [ ] `--output-server FILE` β generate a standalone, shareable server script
|
|
203
|
+
- [ ] Per-operation rate limiting
|
|
204
|
+
- [ ] OAuth2 client-credentials flow
|
|
205
|
+
|
|
206
|
+
## π License
|
|
207
|
+
|
|
208
|
+
MIT β see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mcpify/__init__.py,sha256=ekCwGO_MK9P57-XSxWwS5P57tvptBNcY-tBZw5WEmuA,431
|
|
2
|
+
mcpify/api_server.py,sha256=ArOdSzAcIVQuS1yo5qx4x2kulaHEVGSW2c5ZFkLYF4Y,5122
|
|
3
|
+
mcpify/cli.py,sha256=vDxXbpqmeBtKr699c4KpmPqy83yRYg47ZcdKf8VS10w,9917
|
|
4
|
+
mcpify/http_client.py,sha256=bP0c4fGoP6j-NTy1m13WdGjM-xmtkDQJHl-0fVFPxlo,1716
|
|
5
|
+
mcpify/spec.py,sha256=aYk5nD0qOm_PiTOWuVpyxbVWBcZ9nVe22OTE_ulLLXE,4512
|
|
6
|
+
mcpify/tools.py,sha256=6E5zPTdp84Kz1ThhWX6Npjv5rlnXXpoNp3_BXO58RoM,9492
|
|
7
|
+
mcpify_openapi-1.0.0.dist-info/licenses/LICENSE,sha256=5cHYZ8MoekInkshb56FPflB05NtsdsbuR-794zZZtpg,1071
|
|
8
|
+
mcpify_openapi-1.0.0.dist-info/METADATA,sha256=L6hI-irZhF6HwrzTVw57Vyjd58TdcgAk3hYUs6hfAQw,7637
|
|
9
|
+
mcpify_openapi-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
mcpify_openapi-1.0.0.dist-info/entry_points.txt,sha256=0Fwp_BJIUQ83w1fvqzN7tq9IC-AQoy5fNa4xIE2jyaI,43
|
|
11
|
+
mcpify_openapi-1.0.0.dist-info/top_level.txt,sha256=Ef4ndUJjautEfRkIgRJPfhDvrvjDZRwGXNuzaaQ7fL4,7
|
|
12
|
+
mcpify_openapi-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan GΓΆktan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcpify
|