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 +5 -0
- mcpgen/cli.py +199 -0
- mcpgen/generator/__init__.py +3 -0
- mcpgen/generator/python.py +90 -0
- mcpgen/ir/__init__.py +3 -0
- mcpgen/ir/models.py +87 -0
- mcpgen/parser/__init__.py +3 -0
- mcpgen/parser/loader.py +144 -0
- mcpgen/parser/openapi.py +374 -0
- mcpgen/parser/postman.py +216 -0
- mcpgen/templates/python/requirements.txt.jinja2 +5 -0
- mcpgen/templates/python/server.py.jinja2 +165 -0
- mcpgen_cli-0.1.0.dist-info/METADATA +155 -0
- mcpgen_cli-0.1.0.dist-info/RECORD +16 -0
- mcpgen_cli-0.1.0.dist-info/WHEEL +4 -0
- mcpgen_cli-0.1.0.dist-info/entry_points.txt +2 -0
mcpgen/__init__.py
ADDED
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,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
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("_")
|
mcpgen/parser/loader.py
ADDED
|
@@ -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
|
+
)
|