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 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
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
30
+ ![License](https://img.shields.io/badge/license-MIT-green)
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
+ ![Tests](https://img.shields.io/badge/tests-53%20passed-brightgreen)
38
+ [![CI](https://github.com/furkan708/mcpify/actions/workflows/ci.yml/badge.svg)](https://github.com/furkan708/mcpify/actions/workflows/ci.yml) ![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg) ![Types: mypy](https://img.shields.io/badge/types-mypy-blue)
39
+ ![Dependencies](https://img.shields.io/badge/dependencies-zero-success)
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
+ ![mcpify terminal demo](docs/screenshot.png)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcpify = mcpify.cli:main
@@ -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