mcpgen-cli 0.1.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.
mcpgen/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mcpgen — Turn any API into an MCP server in 30 seconds."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "JnanaSrota"
5
+ __license__ = "MIT"
mcpgen/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """
2
+ mcpgen CLI — main entry point.
3
+
4
+ Usage:
5
+ mcpgen openapi.json
6
+ mcpgen https://petstore3.swagger.io/api/v3/openapi.json
7
+ mcpgen postman.json --output ./my-mcp
8
+ mcpgen openapi.yaml --dry-run
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import typer
17
+ from rich import print as rprint
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.syntax import Syntax
21
+ from rich.table import Table
22
+ from rich import box
23
+
24
+ from mcpgen import __version__
25
+ from mcpgen.parser import load, LoaderError
26
+ from mcpgen.generator import generate_python, generate_dry_run
27
+
28
+ app = typer.Typer(
29
+ name="mcpgen",
30
+ help="Turn any API into an MCP server in 30 seconds.",
31
+ add_completion=False,
32
+ rich_markup_mode="rich",
33
+ )
34
+
35
+ console = Console()
36
+
37
+
38
+ def _version_callback(value: bool):
39
+ if value:
40
+ rprint(f"mcpgen v{__version__}")
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.command()
45
+ def main(
46
+ source: str = typer.Argument(
47
+ ...,
48
+ help="OpenAPI JSON/YAML file path, Postman collection, or URL",
49
+ metavar="INPUT",
50
+ ),
51
+ output: Optional[Path] = typer.Option(
52
+ None,
53
+ "--output", "-o",
54
+ help="Output directory (default: current directory)",
55
+ metavar="DIR",
56
+ ),
57
+ name: Optional[str] = typer.Option(
58
+ None,
59
+ "--name", "-n",
60
+ help="Override the generated server name",
61
+ ),
62
+ dry_run: bool = typer.Option(
63
+ False,
64
+ "--dry-run",
65
+ help="Print generated code without writing files",
66
+ ),
67
+ no_color: bool = typer.Option(
68
+ False,
69
+ "--no-color",
70
+ help="Disable colored output",
71
+ ),
72
+ version: Optional[bool] = typer.Option(
73
+ None,
74
+ "--version", "-v",
75
+ callback=_version_callback,
76
+ is_eager=True,
77
+ help="Show version and exit",
78
+ ),
79
+ ):
80
+ """
81
+ [bold cyan]mcpgen[/bold cyan] — Turn any API into an MCP server in 30 seconds.
82
+
83
+ Examples:
84
+ mcpgen openapi.json
85
+ mcpgen https://petstore3.swagger.io/api/v3/openapi.json
86
+ mcpgen stripe.yaml --output ./stripe-mcp
87
+ mcpgen postman.json --dry-run
88
+ """
89
+
90
+ if no_color:
91
+ console._color_system = None
92
+
93
+ # Header
94
+ console.print()
95
+ console.print(Panel(
96
+ f"[bold cyan]mcpgen[/bold cyan] [dim]v{__version__}[/dim]",
97
+ box=box.ROUNDED,
98
+ expand=False,
99
+ border_style="cyan",
100
+ ))
101
+ console.print()
102
+
103
+ start = time.monotonic()
104
+
105
+ # Step 1: Parse
106
+ with console.status("[cyan]Parsing[/cyan] " + source + " ..."):
107
+ try:
108
+ spec = load(source)
109
+ except LoaderError as e:
110
+ console.print(f" [red]✗ Error:[/red] {e}")
111
+ raise typer.Exit(1)
112
+ except Exception as e:
113
+ console.print(f" [red]✗ Unexpected error:[/red] {e}")
114
+ raise typer.Exit(1)
115
+
116
+ console.print(f" [dim]Parsed[/dim] {source}")
117
+
118
+ # Apply name override
119
+ if name:
120
+ spec = spec.model_copy(update={"name": name})
121
+
122
+ # Step 2: Report what was found
123
+ console.print(f" [dim]Found[/dim] [bold]{len(spec.tools)}[/bold] tool{'s' if len(spec.tools) != 1 else ''} → MCP tools")
124
+
125
+ if spec.auth_type != "none":
126
+ auth_display = {
127
+ "bearer": f"Bearer token [dim](set [bold]{spec.auth_env_var}[/bold] env var)[/dim]",
128
+ "api_key": f"API Key [dim](set [bold]{spec.auth_env_var}[/bold] env var)[/dim]",
129
+ "basic": f"Basic Auth [dim](set [bold]{spec.auth_env_var}[/bold] env var)[/dim]",
130
+ }.get(spec.auth_type, spec.auth_type)
131
+ console.print(f" [dim]Auth[/dim] {auth_display}")
132
+ else:
133
+ console.print(f" [dim]Auth[/dim] None detected")
134
+
135
+ # Step 3: Dry run or generate
136
+ if dry_run:
137
+ console.print()
138
+ console.print("[bold]Generated server.py:[/bold]")
139
+ console.print()
140
+ code = generate_dry_run(spec)
141
+ console.print(Syntax(code, "python", theme="monokai", line_numbers=True))
142
+ raise typer.Exit(0)
143
+
144
+ # Step 4: Write files
145
+ output_dir = output or Path.cwd()
146
+
147
+ with console.status("[cyan]Writing[/cyan] " + str(output_dir / spec.slug) + " ..."):
148
+ created = generate_python(spec, output_dir)
149
+
150
+ console.print(f" [dim]Wrote[/dim] [bold]{output_dir / spec.slug}/[/bold]")
151
+
152
+ # Step 5: Summary
153
+ elapsed = time.monotonic() - start
154
+ console.print()
155
+
156
+ # Tool list (up to 10)
157
+ if spec.tools:
158
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))
159
+ table.add_column("", style="dim")
160
+ table.add_column("")
161
+ for tool in spec.tools[:10]:
162
+ table.add_row("→", f"[bold]{tool.name}[/bold] [dim]{tool.description[:60]}[/dim]")
163
+ if len(spec.tools) > 10:
164
+ table.add_row("", f"[dim]... and {len(spec.tools) - 10} more[/dim]")
165
+ console.print(table)
166
+ console.print()
167
+
168
+ # Claude Desktop config snippet
169
+ server_path = output_dir / spec.slug / "server.py"
170
+ config_dict = {
171
+ "command": "python",
172
+ "args": [str(server_path)],
173
+ }
174
+ if spec.auth_env_var:
175
+ config_dict["env"] = {spec.auth_env_var: "your-key-here"}
176
+
177
+ import json
178
+ config_json = json.dumps(
179
+ {spec.slug: config_dict},
180
+ indent=4
181
+ )
182
+
183
+ console.print(Panel(
184
+ f"[bold]Add to Claude Desktop config:[/bold]\n\n"
185
+ f"[dim]~/Library/Application Support/Claude/claude_desktop_config.json[/dim]\n\n"
186
+ f'[green]{{"mcpServers": {{\n'
187
+ + "\n".join(f" {line}" for line in config_json.splitlines())
188
+ + "\n}}}[/green]",
189
+ box=box.ROUNDED,
190
+ border_style="green",
191
+ expand=False,
192
+ ))
193
+
194
+ console.print()
195
+ console.print(
196
+ f" [bold green]Done[/bold green] in {elapsed:.1f}s → "
197
+ f"[dim]cd {spec.slug} && pip install -r requirements.txt && python server.py[/dim]"
198
+ )
199
+ console.print()
@@ -0,0 +1,3 @@
1
+ from .python import generate_python, generate_dry_run
2
+
3
+ __all__ = ["generate_python", "generate_dry_run"]
@@ -0,0 +1,90 @@
1
+ """
2
+ Python MCP server generator.
3
+
4
+ Consumes an MCPSpec (IR) and renders the Jinja2 templates
5
+ into a complete, standalone Python MCP server directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
13
+
14
+ from mcpgen.ir import MCPSpec
15
+
16
+ # Path to templates dir relative to this file
17
+ TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "python"
18
+
19
+ # Module-level cached Jinja environment (created lazily)
20
+ _JINJA_ENV: Optional[Environment] = None
21
+
22
+ def _get_jinja_env() -> Environment:
23
+ global _JINJA_ENV
24
+ if _JINJA_ENV is None:
25
+ env = Environment(
26
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
27
+ autoescape=select_autoescape(["html"]), # only for .html, not .py
28
+ trim_blocks=True,
29
+ lstrip_blocks=True,
30
+ )
31
+ # Custom filter: convert OpenAPI type to Python type hint
32
+ env.filters["python_type"] = _openapi_to_python_type
33
+ _JINJA_ENV = env
34
+ return _JINJA_ENV
35
+
36
+
37
+ def _openapi_to_python_type(openapi_type: str) -> str:
38
+ mapping = {
39
+ "string": "str",
40
+ "integer": "int",
41
+ "number": "float",
42
+ "boolean": "bool",
43
+ "object": "dict",
44
+ "array": "list",
45
+ }
46
+ return mapping.get(openapi_type, "str")
47
+
48
+
49
+ def generate_python(spec: MCPSpec, output_dir: Path) -> list[Path]:
50
+ """
51
+ Generate a Python MCP server from an MCPSpec.
52
+
53
+ Creates output_dir/{slug}/ with:
54
+ - server.py
55
+ - requirements.txt
56
+
57
+ Returns list of created file paths.
58
+ """
59
+ env = _get_jinja_env()
60
+
61
+ server_dir = output_dir / spec.slug
62
+ server_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ created_files: list[Path] = []
65
+
66
+ # Convert spec to a plain dict to reduce attribute lookup overhead inside Jinja
67
+ spec_dict = spec.model_dump()
68
+
69
+ # Render server.py
70
+ server_template = env.get_template("server.py.jinja2")
71
+ server_code = server_template.render(spec=spec_dict)
72
+ server_path = server_dir / "server.py"
73
+ server_path.write_text(server_code, encoding="utf-8")
74
+ created_files.append(server_path)
75
+
76
+ # Render requirements.txt
77
+ req_template = env.get_template("requirements.txt.jinja2")
78
+ req_text = req_template.render(spec=spec_dict)
79
+ req_path = server_dir / "requirements.txt"
80
+ req_path.write_text(req_text, encoding="utf-8")
81
+ created_files.append(req_path)
82
+
83
+ return created_files
84
+
85
+
86
+ def generate_dry_run(spec: MCPSpec) -> str:
87
+ """Return the generated server.py as a string without writing to disk."""
88
+ env = _get_jinja_env()
89
+ template = env.get_template("server.py.jinja2")
90
+ return template.render(spec=spec.model_dump())
mcpgen/ir/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .models import MCPSpec, Tool, Param
2
+
3
+ __all__ = ["MCPSpec", "Tool", "Param"]
mcpgen/ir/models.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ Internal Representation (IR) for mcpgen.
3
+
4
+ Every parser (OpenAPI, Postman, etc.) outputs an MCPSpec.
5
+ The generator consumes MCPSpec. Nothing else crosses that boundary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from typing import Literal, Optional
10
+ from pydantic import BaseModel, field_validator
11
+ import re
12
+
13
+
14
+ class Param(BaseModel):
15
+ """A single parameter for an API tool call."""
16
+
17
+ name: str # Python-safe identifier, snake_case
18
+ location: Literal["query", "path", "header", "body", "cookie"]
19
+ required: bool = False
20
+ type: str = "string" # OpenAPI primitive: string, integer, number, boolean, object, array
21
+ description: Optional[str] = None
22
+ default: Optional[str] = None
23
+ enum: Optional[list[str]] = None
24
+
25
+ @field_validator("name")
26
+ @classmethod
27
+ def sanitize_name(cls, v: str) -> str:
28
+ """Ensure name is a valid Python identifier."""
29
+ v = re.sub(r"[^a-zA-Z0-9_]", "_", v)
30
+ if v and v[0].isdigit():
31
+ v = "_" + v
32
+ return v or "param"
33
+
34
+
35
+ class Tool(BaseModel):
36
+ """Represents one MCP tool, derived from one API endpoint."""
37
+
38
+ name: str # snake_case MCP tool name, e.g. get_users_id
39
+ description: str # Shown to Claude when it decides which tool to call
40
+ method: str # HTTP method: GET, POST, PUT, DELETE, PATCH
41
+ path: str # URL path template, e.g. /users/{id}
42
+ params: list[Param] = []
43
+ base_url: str
44
+ auth_type: Literal["bearer", "api_key", "basic", "none"] = "none"
45
+ auth_header: Optional[str] = None # For api_key auth: the header name e.g. "X-API-Key"
46
+ content_type: str = "application/json"
47
+
48
+ @field_validator("method")
49
+ @classmethod
50
+ def uppercase_method(cls, v: str) -> str:
51
+ return v.upper()
52
+
53
+ @field_validator("name")
54
+ @classmethod
55
+ def sanitize_tool_name(cls, v: str) -> str:
56
+ # Replace non-alphanumeric with underscore, collapse multiples, strip leading/trailing
57
+ v = re.sub(r"[^a-zA-Z0-9]+", "_", v)
58
+ v = v.strip("_")
59
+ return v.lower() or "unknown_tool"
60
+
61
+
62
+ class MCPSpec(BaseModel):
63
+ """
64
+ The complete specification for one MCP server.
65
+
66
+ This is what parsers produce and what the generator consumes.
67
+ """
68
+
69
+ name: str # Human-readable name, e.g. "Stripe API"
70
+ description: str # Server-level description shown to Claude
71
+ base_url: str # API base URL, e.g. https://api.stripe.com
72
+ tools: list[Tool]
73
+ auth_type: Literal["bearer", "api_key", "basic", "none"] = "none"
74
+ auth_env_var: Optional[str] = None # e.g. "STRIPE_TOKEN"
75
+ version: str = "0.1.0"
76
+
77
+ @property
78
+ def slug(self) -> str:
79
+ """URL/directory-safe name, e.g. 'stripe_api_mcp'."""
80
+ s = re.sub(r"[^a-zA-Z0-9]+", "_", self.name)
81
+ s = s.strip("_").lower()
82
+ return s + "_mcp"
83
+
84
+ @property
85
+ def env_prefix(self) -> str:
86
+ """Env var prefix, e.g. 'STRIPE_API'."""
87
+ return re.sub(r"[^A-Z0-9]+", "_", self.name.upper()).strip("_")
@@ -0,0 +1,3 @@
1
+ from .loader import load, LoaderError
2
+
3
+ __all__ = ["load", "LoaderError"]
@@ -0,0 +1,144 @@
1
+ """
2
+ Loader: detects input format and routes to the correct parser.
3
+
4
+ Accepts:
5
+ - File path: .json, .yaml, .yml
6
+ - URL: fetches content then detects format
7
+
8
+ Detects:
9
+ - OpenAPI 3.x (has "openapi" key starting with "3.")
10
+ - Postman Collection v2.1 (has "info._postman_id" key)
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ # Prefer a fast JSON library when available
19
+ try:
20
+ import orjson as _orjson
21
+ def _loads_json(s: str):
22
+ return _orjson.loads(s)
23
+ except Exception:
24
+ import json as _json
25
+ def _loads_json(s: str):
26
+ return _json.loads(s)
27
+
28
+ import httpx
29
+ import yaml
30
+
31
+ from mcpgen.ir import MCPSpec
32
+ from .openapi import parse_openapi
33
+ from .postman import parse_postman
34
+
35
+
36
+ class LoaderError(Exception):
37
+ """Raised when the input cannot be loaded or format is unrecognized."""
38
+ pass
39
+
40
+
41
+ # Lazy, shared HTTPX client to enable connection reuse across fetches.
42
+ _HTTPX_CLIENT: Optional[httpx.Client] = None
43
+
44
+
45
+ def _get_http_client() -> httpx.Client:
46
+ global _HTTPX_CLIENT
47
+ if _HTTPX_CLIENT is None:
48
+ # Set a sensible default timeout here; callers may override per-request.
49
+ _HTTPX_CLIENT = httpx.Client(timeout=15.0)
50
+ return _HTTPX_CLIENT
51
+
52
+
53
+ def load(source: str) -> MCPSpec:
54
+ """
55
+ Main entry point. Takes a file path or URL string.
56
+ Returns an MCPSpec ready for code generation.
57
+
58
+ Raises LoaderError if format is unrecognized or fetch fails.
59
+ """
60
+ raw = _fetch(source)
61
+ data = _parse_raw(raw, source)
62
+ if source.startswith(("http://", "https://")):
63
+ return parse_openapi(data, source_url=source)
64
+ else:
65
+ return parse_openapi(data)
66
+
67
+
68
+ def _fetch(source: str) -> str:
69
+ """Fetch raw text from a file path or HTTP URL.
70
+
71
+ Uses a shared httpx.Client for connection reuse.
72
+ """
73
+ if source.startswith(("http://", "https://")):
74
+ try:
75
+ client = _get_http_client()
76
+ response = client.get(source, follow_redirects=True)
77
+ response.raise_for_status()
78
+ return response.text
79
+ except httpx.HTTPError as e:
80
+ raise LoaderError(f"Failed to fetch URL: {e}") from e
81
+
82
+ path = Path(source)
83
+ if not path.exists():
84
+ raise LoaderError(f"File not found: {source}")
85
+ return path.read_text(encoding="utf-8")
86
+
87
+
88
+ def _parse_raw(raw: str, source: str) -> dict:
89
+ """
90
+ Parse raw text into a dict.
91
+ Tries JSON first (fast), then YAML. YAML safe_load can also parse JSON,
92
+ so there's no need to re-run JSON a second time.
93
+ """
94
+ src_lower = source.lower()
95
+
96
+ # Try JSON first when likely; fall back to YAML. Use a faster JSON loader when available.
97
+ if src_lower.endswith('.json') or not src_lower.endswith(('.yaml', '.yml')):
98
+ try:
99
+ return _loads_json(raw)
100
+ except Exception:
101
+ # fall through to YAML attempt
102
+ pass
103
+
104
+ # Try YAML (YAML is a superset of JSON so this will also succeed for JSON content)
105
+ try:
106
+ result = yaml.safe_load(raw)
107
+ if isinstance(result, dict):
108
+ return result
109
+ except yaml.YAMLError:
110
+ pass
111
+
112
+ # If we got here, parsing failed
113
+ raise LoaderError(
114
+ f"Could not parse '{source}' as JSON or YAML. "
115
+ "Check that the file is a valid OpenAPI spec or Postman collection."
116
+ )
117
+
118
+
119
+ def _route(data: dict) -> MCPSpec:
120
+ """Detect format and route to the correct parser."""
121
+
122
+ # OpenAPI 3.x detection
123
+ openapi_version = data.get("openapi", "")
124
+ if isinstance(openapi_version, str) and openapi_version.startswith("3."):
125
+ return parse_openapi(data)
126
+
127
+ # Swagger 2.x detection — give a helpful error
128
+ if "swagger" in data:
129
+ raise LoaderError(
130
+ "Swagger 2.x detected. mcpgen currently supports OpenAPI 3.x only. "
131
+ "Convert your spec at https://converter.swagger.io/ and try again."
132
+ )
133
+
134
+ # Postman Collection v2.1 detection
135
+ info = data.get("info", {})
136
+ if "_postman_id" in info or data.get("item"):
137
+ return parse_postman(data)
138
+
139
+ raise LoaderError(
140
+ "Unrecognized format. mcpgen supports:\n"
141
+ " - OpenAPI 3.x JSON/YAML\n"
142
+ " - Postman Collection v2.1\n"
143
+ "Check that your file has the correct structure."
144
+ )