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/parser/openapi.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAPI 3.x parser → MCPSpec (Internal Representation).
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Paths and operations (GET/POST/PUT/DELETE/PATCH)
|
|
6
|
+
- Parameters (path, query, header, cookie)
|
|
7
|
+
- Request bodies (application/json schema properties → body params)
|
|
8
|
+
- Security schemes (bearer, apiKey, basic)
|
|
9
|
+
- $ref resolution for inline schemas (one level deep)
|
|
10
|
+
|
|
11
|
+
Does NOT handle:
|
|
12
|
+
- Recursive $ref resolution (too complex for MVP, mark as TODO)
|
|
13
|
+
- allOf/oneOf/anyOf schema composition
|
|
14
|
+
- OAuth flows (marks as "bearer" approximation)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
from urllib.parse import urljoin
|
|
21
|
+
from mcpgen.ir import MCPSpec, Tool, Param
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Mapping from OpenAPI primitive types to Python type hints
|
|
25
|
+
OPENAPI_TO_PYTHON_TYPE: dict[str, str] = {
|
|
26
|
+
"string": "str",
|
|
27
|
+
"integer": "int",
|
|
28
|
+
"number": "float",
|
|
29
|
+
"boolean": "bool",
|
|
30
|
+
"object": "dict",
|
|
31
|
+
"array": "list",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenAPIParserError(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_openapi(data: dict, source_url:str | None = None) -> MCPSpec:
|
|
40
|
+
"""Parse an OpenAPI 3.x spec dict into an MCPSpec."""
|
|
41
|
+
|
|
42
|
+
info = data.get("info", {})
|
|
43
|
+
name = info.get("title", "API")
|
|
44
|
+
description = info.get("description", f"{name} MCP Server")
|
|
45
|
+
|
|
46
|
+
# Extract base URL from servers array
|
|
47
|
+
servers = data.get("servers", [])
|
|
48
|
+
raw_url = servers[0].get("url", "https://api.example.com") if servers else "https://api.example.com"
|
|
49
|
+
|
|
50
|
+
# Resolve relative URLs
|
|
51
|
+
if source_url and not raw_url.startswith(("http://", "https://")):
|
|
52
|
+
base_url = urljoin(source_url, raw_url)
|
|
53
|
+
elif not raw_url.startswith(("http://", "https://")):
|
|
54
|
+
# No source URL, use a default base
|
|
55
|
+
base_url = urljoin("https://api.example.com", raw_url)
|
|
56
|
+
else:
|
|
57
|
+
base_url = raw_url
|
|
58
|
+
|
|
59
|
+
# Strip trailing slash
|
|
60
|
+
base_url = base_url.rstrip("/")
|
|
61
|
+
|
|
62
|
+
# Detect global auth
|
|
63
|
+
components = data.get("components", {})
|
|
64
|
+
security_schemes = components.get("securitySchemes", {})
|
|
65
|
+
global_security = data.get("security", [])
|
|
66
|
+
|
|
67
|
+
auth_type, auth_header, auth_env_var = _detect_auth(
|
|
68
|
+
security_schemes, global_security, name
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Parse all paths → tools
|
|
72
|
+
paths = data.get("paths", {})
|
|
73
|
+
tools: list[Tool] = []
|
|
74
|
+
|
|
75
|
+
HTTP_METHODS = ["get", "post", "put", "delete", "patch", "head", "options"]
|
|
76
|
+
|
|
77
|
+
for path, path_item in paths.items():
|
|
78
|
+
if not isinstance(path_item, dict):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Path-level parameters (shared across all operations on this path)
|
|
82
|
+
path_level_params = path_item.get("parameters", [])
|
|
83
|
+
|
|
84
|
+
for method in HTTP_METHODS:
|
|
85
|
+
operation = path_item.get(method)
|
|
86
|
+
if not operation or not isinstance(operation, dict):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Skip deprecated operations
|
|
90
|
+
if operation.get("deprecated", False):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
tool = _parse_operation(
|
|
94
|
+
path=path,
|
|
95
|
+
method=method.upper(),
|
|
96
|
+
operation=operation,
|
|
97
|
+
path_level_params=path_level_params,
|
|
98
|
+
base_url=base_url,
|
|
99
|
+
global_auth_type=auth_type,
|
|
100
|
+
global_auth_header=auth_header,
|
|
101
|
+
components=components,
|
|
102
|
+
)
|
|
103
|
+
tools.append(tool)
|
|
104
|
+
|
|
105
|
+
if not tools:
|
|
106
|
+
raise OpenAPIParserError(
|
|
107
|
+
"No operations found in the OpenAPI spec. "
|
|
108
|
+
"Make sure the spec has a 'paths' section with at least one operation."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return MCPSpec(
|
|
112
|
+
name=name,
|
|
113
|
+
description=description,
|
|
114
|
+
base_url=base_url,
|
|
115
|
+
tools=tools,
|
|
116
|
+
auth_type=auth_type,
|
|
117
|
+
auth_env_var=auth_env_var,
|
|
118
|
+
version=info.get("version", "0.1.0"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _detect_auth(
|
|
123
|
+
security_schemes: dict,
|
|
124
|
+
global_security: list,
|
|
125
|
+
api_name: str,
|
|
126
|
+
) -> tuple[str, Optional[str], Optional[str]]:
|
|
127
|
+
"""
|
|
128
|
+
Returns (auth_type, auth_header_name, env_var_name).
|
|
129
|
+
auth_type is one of: "bearer", "api_key", "basic", "none"
|
|
130
|
+
"""
|
|
131
|
+
if not security_schemes:
|
|
132
|
+
return "none", None, None
|
|
133
|
+
|
|
134
|
+
# Prefer the first scheme referenced in global security
|
|
135
|
+
active_scheme_name = None
|
|
136
|
+
if global_security:
|
|
137
|
+
for sec_req in global_security:
|
|
138
|
+
if isinstance(sec_req, dict) and sec_req:
|
|
139
|
+
active_scheme_name = next(iter(sec_req.keys()))
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
# Fall back to first defined scheme
|
|
143
|
+
if not active_scheme_name and security_schemes:
|
|
144
|
+
active_scheme_name = next(iter(security_schemes.keys()))
|
|
145
|
+
|
|
146
|
+
if not active_scheme_name:
|
|
147
|
+
return "none", None, None
|
|
148
|
+
|
|
149
|
+
scheme = security_schemes.get(active_scheme_name, {})
|
|
150
|
+
scheme_type = scheme.get("type", "").lower()
|
|
151
|
+
|
|
152
|
+
env_prefix = re.sub(r"[^A-Z0-9]+", "_", api_name.upper()).strip("_")
|
|
153
|
+
|
|
154
|
+
if scheme_type == "http":
|
|
155
|
+
http_scheme = scheme.get("scheme", "").lower()
|
|
156
|
+
if http_scheme == "bearer":
|
|
157
|
+
return "bearer", "Authorization", f"{env_prefix}_TOKEN"
|
|
158
|
+
elif http_scheme == "basic":
|
|
159
|
+
return "basic", "Authorization", f"{env_prefix}_CREDENTIALS"
|
|
160
|
+
|
|
161
|
+
elif scheme_type == "apikey":
|
|
162
|
+
header_name = scheme.get("name", "X-API-Key")
|
|
163
|
+
location = scheme.get("in", "header")
|
|
164
|
+
if location == "header":
|
|
165
|
+
return "api_key", header_name, f"{env_prefix}_API_KEY"
|
|
166
|
+
|
|
167
|
+
elif scheme_type == "oauth2":
|
|
168
|
+
# Approximate OAuth2 as bearer — user provides their own token
|
|
169
|
+
return "bearer", "Authorization", f"{env_prefix}_TOKEN"
|
|
170
|
+
|
|
171
|
+
return "none", None, None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_operation(
|
|
175
|
+
path: str,
|
|
176
|
+
method: str,
|
|
177
|
+
operation: dict,
|
|
178
|
+
path_level_params: list,
|
|
179
|
+
base_url: str,
|
|
180
|
+
global_auth_type: str,
|
|
181
|
+
global_auth_header: Optional[str],
|
|
182
|
+
components: dict,
|
|
183
|
+
) -> Tool:
|
|
184
|
+
"""Convert one OpenAPI operation into one Tool."""
|
|
185
|
+
|
|
186
|
+
# Generate tool name from operationId or method+path
|
|
187
|
+
operation_id = operation.get("operationId")
|
|
188
|
+
if operation_id:
|
|
189
|
+
# Convert camelCase to snake_case
|
|
190
|
+
tool_name = _camel_to_snake(operation_id)
|
|
191
|
+
else:
|
|
192
|
+
# Build from method and path: GET /users/{id} → get_users_id
|
|
193
|
+
clean_path = re.sub(r"[{}]", "", path) # remove braces
|
|
194
|
+
clean_path = re.sub(r"[^a-zA-Z0-9/]", "_", clean_path) # non-alnum → _
|
|
195
|
+
clean_path = re.sub(r"/+", "_", clean_path) # slashes → _
|
|
196
|
+
tool_name = f"{method.lower()}_{clean_path}"
|
|
197
|
+
|
|
198
|
+
# Clean up the tool name
|
|
199
|
+
tool_name = re.sub(r"_+", "_", tool_name).strip("_").lower()
|
|
200
|
+
|
|
201
|
+
# Description: prefer operation summary, then description, then fallback
|
|
202
|
+
description = (
|
|
203
|
+
operation.get("summary")
|
|
204
|
+
or operation.get("description")
|
|
205
|
+
or f"{method} {path}"
|
|
206
|
+
)
|
|
207
|
+
# Truncate to 200 chars (MCP tool description limit)
|
|
208
|
+
description = description[:200]
|
|
209
|
+
|
|
210
|
+
# Merge path-level params with operation-level params
|
|
211
|
+
# Operation-level overrides path-level for same name+location
|
|
212
|
+
all_raw_params = list(path_level_params) + list(operation.get("parameters", []))
|
|
213
|
+
|
|
214
|
+
# Deduplicate: operation-level params win
|
|
215
|
+
seen: dict[tuple, dict] = {}
|
|
216
|
+
for p in all_raw_params:
|
|
217
|
+
key = (p.get("name"), p.get("in"))
|
|
218
|
+
seen[key] = p
|
|
219
|
+
|
|
220
|
+
params: list[Param] = []
|
|
221
|
+
|
|
222
|
+
for raw_param in seen.values():
|
|
223
|
+
# Resolve $ref if present
|
|
224
|
+
raw_param = _resolve_ref(raw_param, components)
|
|
225
|
+
param = _parse_parameter(raw_param)
|
|
226
|
+
if param:
|
|
227
|
+
params.append(param)
|
|
228
|
+
|
|
229
|
+
# Parse requestBody → body params
|
|
230
|
+
request_body = operation.get("requestBody", {})
|
|
231
|
+
if request_body:
|
|
232
|
+
request_body = _resolve_ref(request_body, components)
|
|
233
|
+
body_params = _parse_request_body(request_body, components)
|
|
234
|
+
params.extend(body_params)
|
|
235
|
+
|
|
236
|
+
# Sort: required params first, then by name
|
|
237
|
+
params.sort(key=lambda p: (not p.required, p.name))
|
|
238
|
+
|
|
239
|
+
# Determine operation-level auth (can be overridden per-operation)
|
|
240
|
+
op_security = operation.get("security")
|
|
241
|
+
if op_security is not None:
|
|
242
|
+
# Empty list means no auth for this operation
|
|
243
|
+
if not op_security:
|
|
244
|
+
auth_type = "none"
|
|
245
|
+
auth_header = None
|
|
246
|
+
else:
|
|
247
|
+
auth_type = global_auth_type
|
|
248
|
+
auth_header = global_auth_header
|
|
249
|
+
else:
|
|
250
|
+
auth_type = global_auth_type
|
|
251
|
+
auth_header = global_auth_header
|
|
252
|
+
|
|
253
|
+
return Tool(
|
|
254
|
+
name=tool_name,
|
|
255
|
+
description=description,
|
|
256
|
+
method=method,
|
|
257
|
+
path=path,
|
|
258
|
+
params=params,
|
|
259
|
+
base_url=base_url,
|
|
260
|
+
auth_type=auth_type,
|
|
261
|
+
auth_header=auth_header,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_parameter(raw: dict) -> Optional[Param]:
|
|
266
|
+
"""Parse a single OpenAPI parameter dict into a Param."""
|
|
267
|
+
name = raw.get("name")
|
|
268
|
+
location = raw.get("in")
|
|
269
|
+
|
|
270
|
+
if not name or not location:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
if location not in ("query", "path", "header", "cookie"):
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
# Extract schema
|
|
277
|
+
schema = raw.get("schema", {})
|
|
278
|
+
param_type = schema.get("type", "string")
|
|
279
|
+
enum = schema.get("enum")
|
|
280
|
+
default = schema.get("default")
|
|
281
|
+
|
|
282
|
+
return Param(
|
|
283
|
+
name=name,
|
|
284
|
+
location=location,
|
|
285
|
+
required=raw.get("required", location == "path"), # path params always required
|
|
286
|
+
type=param_type,
|
|
287
|
+
description=raw.get("description"),
|
|
288
|
+
default=str(default) if default is not None else None,
|
|
289
|
+
enum=[str(e) for e in enum] if enum else None,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_request_body(request_body: dict, components: dict) -> list[Param]:
|
|
294
|
+
"""
|
|
295
|
+
Parse requestBody into a flat list of Params with location="body".
|
|
296
|
+
|
|
297
|
+
We flatten one level of schema properties. Deep nesting becomes a single
|
|
298
|
+
"body" param of type "object" — the generated code passes it as-is.
|
|
299
|
+
"""
|
|
300
|
+
content = request_body.get("content", {})
|
|
301
|
+
|
|
302
|
+
# Prefer application/json, fall back to first available
|
|
303
|
+
schema = None
|
|
304
|
+
if "application/json" in content:
|
|
305
|
+
schema = content["application/json"].get("schema", {})
|
|
306
|
+
elif content:
|
|
307
|
+
first_content = next(iter(content.values()))
|
|
308
|
+
schema = first_content.get("schema", {})
|
|
309
|
+
|
|
310
|
+
if not schema:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
# Resolve $ref
|
|
314
|
+
schema = _resolve_ref(schema, components)
|
|
315
|
+
|
|
316
|
+
required_fields: set[str] = set(schema.get("required", []))
|
|
317
|
+
properties: dict = schema.get("properties", {})
|
|
318
|
+
|
|
319
|
+
if not properties:
|
|
320
|
+
# Treat entire body as one opaque object param
|
|
321
|
+
return [Param(
|
|
322
|
+
name="body",
|
|
323
|
+
location="body",
|
|
324
|
+
required=request_body.get("required", False),
|
|
325
|
+
type="object",
|
|
326
|
+
description="Request body",
|
|
327
|
+
)]
|
|
328
|
+
|
|
329
|
+
params = []
|
|
330
|
+
for prop_name, prop_schema in properties.items():
|
|
331
|
+
prop_schema = _resolve_ref(prop_schema, components)
|
|
332
|
+
params.append(Param(
|
|
333
|
+
name=prop_name,
|
|
334
|
+
location="body",
|
|
335
|
+
required=prop_name in required_fields,
|
|
336
|
+
type=prop_schema.get("type", "string"),
|
|
337
|
+
description=prop_schema.get("description"),
|
|
338
|
+
enum=[str(e) for e in prop_schema["enum"]] if "enum" in prop_schema else None,
|
|
339
|
+
))
|
|
340
|
+
|
|
341
|
+
return params
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _resolve_ref(obj: dict, components: dict) -> dict:
|
|
345
|
+
"""
|
|
346
|
+
Resolve a single-level $ref.
|
|
347
|
+
Only handles '#/components/...' references.
|
|
348
|
+
Does NOT recursively resolve nested $refs (TODO for v0.2).
|
|
349
|
+
"""
|
|
350
|
+
if "$ref" not in obj:
|
|
351
|
+
return obj
|
|
352
|
+
|
|
353
|
+
ref = obj["$ref"]
|
|
354
|
+
if not ref.startswith("#/components/"):
|
|
355
|
+
return obj # External refs not supported
|
|
356
|
+
|
|
357
|
+
# "#/components/schemas/Pet" → ["schemas", "Pet"]
|
|
358
|
+
parts = ref.lstrip("#/").split("/")
|
|
359
|
+
# parts = ["components", "schemas", "Pet"]
|
|
360
|
+
|
|
361
|
+
result = components
|
|
362
|
+
for part in parts[1:]: # skip "components"
|
|
363
|
+
if not isinstance(result, dict):
|
|
364
|
+
return obj
|
|
365
|
+
result = result.get(part, {})
|
|
366
|
+
|
|
367
|
+
return result if isinstance(result, dict) else obj
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _camel_to_snake(name: str) -> str:
|
|
371
|
+
"""Convert camelCase or PascalCase to snake_case."""
|
|
372
|
+
# Insert underscore before uppercase letters
|
|
373
|
+
s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
374
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
mcpgen/parser/postman.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Postman Collection v2.1 parser → MCPSpec.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Flat and nested folder structures (recursively flattens)
|
|
6
|
+
- Variable substitution for {{baseUrl}}
|
|
7
|
+
- Basic auth and API key detection from collection auth
|
|
8
|
+
|
|
9
|
+
Does NOT handle:
|
|
10
|
+
- Pre-request scripts
|
|
11
|
+
- Dynamic variables ({{$randomEmail}}, etc.)
|
|
12
|
+
- OAuth flows
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import re
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from mcpgen.ir import MCPSpec, Tool, Param
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_postman(data: dict) -> MCPSpec:
|
|
23
|
+
"""Parse a Postman Collection v2.1 dict into an MCPSpec."""
|
|
24
|
+
|
|
25
|
+
info = data.get("info", {})
|
|
26
|
+
name = info.get("name", "Postman Collection")
|
|
27
|
+
description = info.get("description", f"{name} MCP Server")
|
|
28
|
+
if isinstance(description, dict):
|
|
29
|
+
description = description.get("content", name)
|
|
30
|
+
|
|
31
|
+
# Extract variables for substitution (e.g. {{baseUrl}})
|
|
32
|
+
variables: dict[str, str] = {}
|
|
33
|
+
for var in data.get("variable", []):
|
|
34
|
+
if isinstance(var, dict) and var.get("key"):
|
|
35
|
+
variables[var["key"]] = str(var.get("value", ""))
|
|
36
|
+
|
|
37
|
+
# Detect auth
|
|
38
|
+
collection_auth = data.get("auth", {})
|
|
39
|
+
auth_type, auth_header, auth_env_var = _detect_postman_auth(collection_auth, name)
|
|
40
|
+
|
|
41
|
+
# Flatten all items (folders → flat list of requests)
|
|
42
|
+
requests = _flatten_items(data.get("item", []))
|
|
43
|
+
|
|
44
|
+
if not requests:
|
|
45
|
+
raise ValueError("No requests found in Postman collection.")
|
|
46
|
+
|
|
47
|
+
# Extract base URL from first request
|
|
48
|
+
base_url = _extract_base_url(requests[0], variables)
|
|
49
|
+
|
|
50
|
+
tools = []
|
|
51
|
+
for req_item in requests:
|
|
52
|
+
tool = _parse_request(req_item, variables, base_url, auth_type, auth_header)
|
|
53
|
+
if tool:
|
|
54
|
+
tools.append(tool)
|
|
55
|
+
|
|
56
|
+
return MCPSpec(
|
|
57
|
+
name=name,
|
|
58
|
+
description=description,
|
|
59
|
+
base_url=base_url,
|
|
60
|
+
tools=tools,
|
|
61
|
+
auth_type=auth_type,
|
|
62
|
+
auth_env_var=auth_env_var,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _flatten_items(items: list, prefix: str = "") -> list[dict]:
|
|
67
|
+
"""Recursively flatten nested folders into a flat list of request items."""
|
|
68
|
+
result = []
|
|
69
|
+
for item in items:
|
|
70
|
+
if not isinstance(item, dict):
|
|
71
|
+
continue
|
|
72
|
+
if "item" in item:
|
|
73
|
+
# It's a folder — recurse
|
|
74
|
+
folder_name = item.get("name", "")
|
|
75
|
+
result.extend(_flatten_items(item["item"], f"{prefix}{folder_name}/"))
|
|
76
|
+
elif "request" in item:
|
|
77
|
+
# It's a request — include with folder prefix
|
|
78
|
+
result.append({"_folder_prefix": prefix, **item})
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_request(
|
|
83
|
+
item: dict,
|
|
84
|
+
variables: dict,
|
|
85
|
+
base_url: str,
|
|
86
|
+
auth_type: str,
|
|
87
|
+
auth_header: Optional[str],
|
|
88
|
+
) -> Optional[Tool]:
|
|
89
|
+
"""Parse one Postman request item into a Tool."""
|
|
90
|
+
|
|
91
|
+
request = item.get("request", {})
|
|
92
|
+
if not request or not isinstance(request, dict):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
name = item.get("name", "unknown")
|
|
96
|
+
method = request.get("method", "GET").upper()
|
|
97
|
+
|
|
98
|
+
# Build tool name from request name
|
|
99
|
+
tool_name = re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower()
|
|
100
|
+
tool_name = f"{method.lower()}_{tool_name}"
|
|
101
|
+
|
|
102
|
+
description = request.get("description", name)
|
|
103
|
+
if isinstance(description, dict):
|
|
104
|
+
description = description.get("content", name)
|
|
105
|
+
description = str(description)[:200]
|
|
106
|
+
|
|
107
|
+
# Extract URL
|
|
108
|
+
url_obj = request.get("url", {})
|
|
109
|
+
if isinstance(url_obj, str):
|
|
110
|
+
full_url = _substitute_vars(url_obj, variables)
|
|
111
|
+
path = "/" + "/".join(full_url.replace(base_url, "").lstrip("/").split("/"))
|
|
112
|
+
elif isinstance(url_obj, dict):
|
|
113
|
+
raw = url_obj.get("raw", "")
|
|
114
|
+
raw = _substitute_vars(raw, variables)
|
|
115
|
+
path_parts = url_obj.get("path", [])
|
|
116
|
+
path = "/" + "/".join(str(p) for p in path_parts)
|
|
117
|
+
path = _substitute_vars(path, variables)
|
|
118
|
+
# Convert Postman :param notation to OpenAPI {param} notation
|
|
119
|
+
path = re.sub(r":([a-zA-Z_][a-zA-Z0-9_]*)", r"{\1}", path)
|
|
120
|
+
else:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
params: list[Param] = []
|
|
124
|
+
|
|
125
|
+
# Query params
|
|
126
|
+
if isinstance(url_obj, dict):
|
|
127
|
+
for qp in url_obj.get("query", []):
|
|
128
|
+
if isinstance(qp, dict) and qp.get("key"):
|
|
129
|
+
params.append(Param(
|
|
130
|
+
name=qp["key"],
|
|
131
|
+
location="query",
|
|
132
|
+
required=False,
|
|
133
|
+
type="string",
|
|
134
|
+
description=qp.get("description", ""),
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
# Path params
|
|
138
|
+
if isinstance(url_obj, dict):
|
|
139
|
+
for pp in url_obj.get("variable", []):
|
|
140
|
+
if isinstance(pp, dict) and pp.get("key"):
|
|
141
|
+
params.append(Param(
|
|
142
|
+
name=pp["key"],
|
|
143
|
+
location="path",
|
|
144
|
+
required=True,
|
|
145
|
+
type="string",
|
|
146
|
+
description=pp.get("description", ""),
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
# Body params (raw JSON body)
|
|
150
|
+
body = request.get("body", {})
|
|
151
|
+
if isinstance(body, dict) and body.get("mode") == "raw":
|
|
152
|
+
params.append(Param(
|
|
153
|
+
name="body",
|
|
154
|
+
location="body",
|
|
155
|
+
required=True,
|
|
156
|
+
type="object",
|
|
157
|
+
description="Request body (JSON)",
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
return Tool(
|
|
161
|
+
name=tool_name,
|
|
162
|
+
description=description,
|
|
163
|
+
method=method,
|
|
164
|
+
path=path,
|
|
165
|
+
params=params,
|
|
166
|
+
base_url=base_url,
|
|
167
|
+
auth_type=auth_type,
|
|
168
|
+
auth_header=auth_header,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extract_base_url(request_item: dict, variables: dict) -> str:
|
|
173
|
+
"""Extract base URL from the first request."""
|
|
174
|
+
request = request_item.get("request", {})
|
|
175
|
+
url_obj = request.get("url", {})
|
|
176
|
+
|
|
177
|
+
if isinstance(url_obj, str):
|
|
178
|
+
url = _substitute_vars(url_obj, variables)
|
|
179
|
+
elif isinstance(url_obj, dict):
|
|
180
|
+
raw = url_obj.get("raw", "")
|
|
181
|
+
url = _substitute_vars(raw, variables)
|
|
182
|
+
else:
|
|
183
|
+
return "https://api.example.com"
|
|
184
|
+
|
|
185
|
+
# Extract scheme + host
|
|
186
|
+
import re
|
|
187
|
+
match = re.match(r"(https?://[^/]+)", url)
|
|
188
|
+
return match.group(1) if match else "https://api.example.com"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _detect_postman_auth(auth: dict, api_name: str) -> tuple[str, Optional[str], Optional[str]]:
|
|
192
|
+
"""Detect auth type from Postman collection auth object."""
|
|
193
|
+
if not auth:
|
|
194
|
+
return "none", None, None
|
|
195
|
+
|
|
196
|
+
env_prefix = re.sub(r"[^A-Z0-9]+", "_", api_name.upper()).strip("_")
|
|
197
|
+
auth_type = auth.get("type", "noauth")
|
|
198
|
+
|
|
199
|
+
if auth_type == "bearer":
|
|
200
|
+
return "bearer", "Authorization", f"{env_prefix}_TOKEN"
|
|
201
|
+
elif auth_type == "apikey":
|
|
202
|
+
key_items = {item["key"]: item.get("value") for item in auth.get("apikey", [])}
|
|
203
|
+
header_name = key_items.get("key", "X-API-Key")
|
|
204
|
+
return "api_key", header_name, f"{env_prefix}_API_KEY"
|
|
205
|
+
elif auth_type == "basic":
|
|
206
|
+
return "basic", "Authorization", f"{env_prefix}_CREDENTIALS"
|
|
207
|
+
|
|
208
|
+
return "none", None, None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _substitute_vars(text: str, variables: dict) -> str:
|
|
212
|
+
"""Replace {{varName}} with the variable value."""
|
|
213
|
+
def replace(match):
|
|
214
|
+
key = match.group(1).strip()
|
|
215
|
+
return variables.get(key, match.group(0))
|
|
216
|
+
return re.sub(r"\{\{([^}]+)\}\}", replace, text)
|