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.
@@ -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()
@@ -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)
@@ -0,0 +1,5 @@
1
+ # {{ spec.name }} MCP Server — Dependencies
2
+ # Generated by mcpgen
3
+
4
+ httpx>=0.27.0
5
+ mcp>=1.0.0