cloudsmith-cli 1.12.0__py2.py3-none-any.whl → 1.12.1__py2.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,792 @@
1
+ import asyncio
2
+ import copy
3
+ import inspect
4
+ import json
5
+ from typing import Any, Dict, List, Optional
6
+ from urllib import parse
7
+
8
+ import cloudsmith_api
9
+ import httpx
10
+ import toon_python as toon
11
+ from mcp import types
12
+ from mcp.server.fastmcp import FastMCP
13
+ from mcp.shared._httpx_utils import create_mcp_http_client
14
+
15
+ from .data import OpenAPITool
16
+
17
+ ALLOWED_METHODS = ["get", "post", "put", "delete", "patch"]
18
+
19
+ API_VERSIONS_TO_DISCOVER = {
20
+ "v1": "swagger/?format=openapi",
21
+ "v2": "openapi/?format=json",
22
+ }
23
+ TOOL_DELETE_SUFFIXES = ["delete", "destroy", "remove"]
24
+
25
+ TOOL_READ_ONLY_SUFFIXES = ["read", "list", "retrieve"]
26
+
27
+ # Common action suffixes in OpenAPI operation IDs
28
+ # These should not be considered as part of the resource group hierarchy
29
+ TOOL_ACTION_SUFFIXES = [
30
+ "create",
31
+ "read",
32
+ "list",
33
+ "update",
34
+ "partial_update",
35
+ "delete",
36
+ "destroy",
37
+ "retrieve",
38
+ "remove",
39
+ ]
40
+
41
+ DEFAULT_DISABLED_CATEGORIES = [
42
+ "broadcasts",
43
+ "rates",
44
+ "packages_upload",
45
+ "packages_validate",
46
+ "user_token",
47
+ "user_tokens",
48
+ "webhooks",
49
+ "status",
50
+ "repos_ecdsa",
51
+ "repos_geoip",
52
+ "repos_gpg",
53
+ "repos_rsa",
54
+ "repos_x509",
55
+ "repos_upstream",
56
+ "orgs_openid",
57
+ "orgs_saml",
58
+ "orgs_invites",
59
+ "files",
60
+ "badges",
61
+ "quota",
62
+ "users_profile",
63
+ "workspaces_policies",
64
+ "storage_regions",
65
+ "entitlements",
66
+ "metrics_entitlements",
67
+ "metrics_packages",
68
+ "orgs_teams",
69
+ "repo_retention",
70
+ ]
71
+
72
+ SERVER_NAME = "Cloudsmith MCP Server"
73
+
74
+
75
+ class CustomFastMCP(FastMCP):
76
+ """Custom FastMCP that overrides tool listing to clean up schemas to not overwhelm the LLM context"""
77
+
78
+ def __init__(self, *args, **kwargs):
79
+ super().__init__(*args, **kwargs)
80
+
81
+ async def list_tools(self) -> list[types.Tool]:
82
+ """Override to clean up tool schemas"""
83
+ # Get the default tools from parent (returns list[MCPTool])
84
+ default_tools = await super().list_tools()
85
+
86
+ # Clean up each tool's schema
87
+ cleaned_tools = []
88
+ for tool in default_tools:
89
+ # Create a new MCPTool with cleaned schema
90
+ cleaned_tool = types.Tool(
91
+ name=tool.name,
92
+ description=tool.description,
93
+ inputSchema=self._clean_schema(tool.inputSchema),
94
+ annotations=tool.annotations,
95
+ )
96
+ cleaned_tools.append(cleaned_tool)
97
+
98
+ return cleaned_tools
99
+
100
+ def _clean_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
101
+ """Clean up schema by removing anyOf patterns and other complexities"""
102
+ if not isinstance(schema, dict):
103
+ return schema
104
+
105
+ cleaned = copy.deepcopy(schema)
106
+
107
+ # Clean properties recursively
108
+ if "properties" in cleaned:
109
+ cleaned_properties = {}
110
+ for prop_name, prop_schema in cleaned["properties"].items():
111
+ cleaned_properties[prop_name] = self._clean_property_schema(prop_schema)
112
+ cleaned["properties"] = cleaned_properties
113
+
114
+ return cleaned
115
+
116
+ def _clean_property_schema(self, prop_schema: Dict[str, Any]) -> Dict[str, Any]:
117
+ """Clean individual property schema"""
118
+ if not isinstance(prop_schema, dict):
119
+ return prop_schema
120
+
121
+ cleaned = copy.deepcopy(prop_schema)
122
+
123
+ # Handle anyOf patterns - extract the non-null type
124
+ if "anyOf" in cleaned:
125
+ non_null_schemas = [
126
+ item
127
+ for item in cleaned["anyOf"]
128
+ if not (isinstance(item, dict) and item.get("type") == "null")
129
+ ]
130
+
131
+ if len(non_null_schemas) == 1:
132
+ # Replace anyOf with the single non-null type
133
+ non_null_schema = non_null_schemas[0]
134
+
135
+ # Merge the non-null schema properties
136
+ for key, value in non_null_schema.items():
137
+ if key not in cleaned or key == "type":
138
+ cleaned[key] = value
139
+
140
+ # Remove the anyOf
141
+ del cleaned["anyOf"]
142
+
143
+ # Handle oneOf with single option
144
+ if "oneOf" in cleaned and len(cleaned["oneOf"]) == 1:
145
+ single_schema = cleaned["oneOf"][0]
146
+ for key, value in single_schema.items():
147
+ if key not in cleaned or key == "type":
148
+ cleaned[key] = value
149
+ del cleaned["oneOf"]
150
+
151
+ # Remove nullable indicators
152
+ if "nullable" in cleaned:
153
+ del cleaned["nullable"]
154
+
155
+ # Clean up title if it's auto-generated and not useful
156
+ if "title" in cleaned and cleaned["title"].endswith("Arguments"):
157
+ del cleaned["title"]
158
+
159
+ # Recursively clean nested schemas
160
+ if "properties" in cleaned:
161
+ nested_properties = {}
162
+ for nested_name, nested_schema in cleaned["properties"].items():
163
+ nested_properties[nested_name] = self._clean_property_schema(
164
+ nested_schema
165
+ )
166
+ cleaned["properties"] = nested_properties
167
+
168
+ if "items" in cleaned:
169
+ cleaned["items"] = self._clean_property_schema(cleaned["items"])
170
+
171
+ return cleaned
172
+
173
+
174
+ class DynamicMCPServer:
175
+ """MCP Server that dynamically generates tools from Cloudsmith's OpenAPI specs"""
176
+
177
+ def __init__(
178
+ self,
179
+ api_config: cloudsmith_api.Configuration = None,
180
+ use_toon=True,
181
+ allow_destructive_tools=False,
182
+ debug_mode=False,
183
+ allowed_tool_groups: Optional[List[str]] = None,
184
+ allowed_tools: Optional[List[str]] = None,
185
+ force_all_tools: bool = False,
186
+ ):
187
+ mcp_kwargs = {"log_level": "ERROR"}
188
+ if debug_mode:
189
+ mcp_kwargs["log_level"] = "DEBUG"
190
+ self.mcp = CustomFastMCP(SERVER_NAME, **mcp_kwargs)
191
+ self.api_config = api_config
192
+ self.api_base_url = api_config.host
193
+ self.use_toon = use_toon
194
+ self.allow_destructive_tools = allow_destructive_tools
195
+ self.allowed_tool_groups = set(allowed_tool_groups or [])
196
+ self.allowed_tools = set(allowed_tools or [])
197
+ self.force_all_tools = force_all_tools
198
+ self.tools: Dict[str, OpenAPITool] = {}
199
+ self.spec = {}
200
+
201
+ async def load_openapi_spec(self):
202
+ """Load OpenAPI spec and generate tools dynamically"""
203
+
204
+ if not self.api_base_url:
205
+ raise Exception("The Cloudsmith API has to be set")
206
+
207
+ async with create_mcp_http_client(
208
+ timeout=30.0, headers=self._get_additional_headers()
209
+ ) as http_client:
210
+ for version, endpoint in API_VERSIONS_TO_DISCOVER.items():
211
+ spec_url = f"{self.api_base_url}/{version}/{endpoint}"
212
+ try:
213
+ response = await http_client.get(spec_url)
214
+ response.raise_for_status()
215
+ except httpx.HTTPStatusError:
216
+ # This version is not available, try the next one
217
+ continue
218
+ self.spec = response.json()
219
+ await self._generate_tools_from_spec()
220
+ # Stop after the first successful spec load to avoid duplicate tools
221
+ break
222
+
223
+ def _get_tool_groups(self, tool_name: str) -> List[str]:
224
+ """
225
+ Extract all hierarchical group names from a tool name, excluding action suffixes.
226
+
227
+ Examples:
228
+ webhooks_create -> ['webhooks']
229
+ repos_upstream_swift_list -> ['repos', 'repos_upstream', 'repos_upstream_swift']
230
+ vulnerabilities_read -> ['vulnerabilities']
231
+ workspaces_policies_actions_partial_update -> ['workspaces', 'workspaces_policies']
232
+ repos_upstream_huggingface_partial_update -> ['repos', 'repos_upstream', 'repos_upstream_huggingface']
233
+ """
234
+ groups = []
235
+ parts = tool_name.split("_")
236
+
237
+ # Determine how many parts belong to the action suffix
238
+ # Sort by length descending to match longest suffixes first
239
+ sorted_suffixes = sorted(
240
+ TOOL_ACTION_SUFFIXES, key=lambda x: len(x.split("_")), reverse=True
241
+ )
242
+
243
+ action_parts_count = 0
244
+ for action_suffix in sorted_suffixes:
245
+ action_suffix_parts = action_suffix.split("_")
246
+ if len(parts) >= len(action_suffix_parts):
247
+ # Check if the end of the tool name matches this action suffix
248
+ if parts[-len(action_suffix_parts) :] == action_suffix_parts:
249
+ action_parts_count = len(action_suffix_parts)
250
+ break
251
+
252
+ # If no action suffix found, treat the last part as the action
253
+ if action_parts_count == 0:
254
+ action_parts_count = 1
255
+
256
+ # Build hierarchical groups by progressively adding parts, excluding action suffix
257
+ resource_parts = (
258
+ parts[:-action_parts_count] if action_parts_count > 0 else parts
259
+ )
260
+ for i in range(1, len(resource_parts) + 1):
261
+ group = "_".join(resource_parts[:i])
262
+ groups.append(group)
263
+
264
+ return groups
265
+
266
+ def _is_tool_destructive(self, tool_name: str) -> bool:
267
+ return any(suffix in tool_name for suffix in TOOL_DELETE_SUFFIXES)
268
+
269
+ def _is_tool_read_only(self, tool_name: str) -> bool:
270
+ return any(suffix in tool_name for suffix in TOOL_READ_ONLY_SUFFIXES)
271
+
272
+ def _is_tool_allowed(self, tool_name: str) -> bool:
273
+ """Check if a tool is allowed based on user configuration"""
274
+
275
+ if self.force_all_tools:
276
+ return True
277
+
278
+ # Check if tool is destructive and destructive tools are disabled
279
+ if not self.allow_destructive_tools and self._is_tool_destructive(tool_name):
280
+ return False
281
+
282
+ tool_groups = self._get_tool_groups(tool_name)
283
+
284
+ # If user provided their own list of allowed tools or tool groups
285
+ if len(self.allowed_tools) > 0 or len(self.allowed_tool_groups) > 0:
286
+ allowed_tool_group = bool(set(tool_groups) & set(self.allowed_tool_groups))
287
+ allowed_tool = tool_name in self.allowed_tools
288
+ return allowed_tool or allowed_tool_group
289
+
290
+ # Otherwise disable all categories in the default list
291
+ return not any(group in DEFAULT_DISABLED_CATEGORIES for group in tool_groups)
292
+
293
+ async def _generate_tools_from_spec(self):
294
+ """Generate MCP tools from OpenAPI specification"""
295
+
296
+ if not self.spec:
297
+ raise ValueError("OpenAPI spec not loaded")
298
+
299
+ # Parse paths and generate tools
300
+ for path, path_item in self.spec.get("paths", {}).items():
301
+ for method, operation in path_item.items():
302
+ path_parameters = path_item.get("parameters", [])
303
+
304
+ if method.lower() in ALLOWED_METHODS:
305
+ tool = self._create_tool_from_operation(
306
+ method.upper(),
307
+ path,
308
+ operation,
309
+ path_parameters,
310
+ self.api_base_url,
311
+ )
312
+ if tool and self._is_tool_allowed(tool.name):
313
+ self.tools[tool.name] = tool
314
+ self._register_dynamic_tool(tool)
315
+
316
+ def _register_dynamic_tool(self, api_tool: OpenAPITool):
317
+ """Register a single tool dynamically with the MCP server"""
318
+
319
+ # Create the tool function dynamically
320
+ async def dynamic_tool_func(**kwargs) -> str:
321
+ return await self._execute_api_call(api_tool, kwargs)
322
+
323
+ # Set function metadata for MCP
324
+ dynamic_tool_func.__name__ = api_tool.name
325
+
326
+ docstring_parts = [api_tool.description]
327
+ properties = api_tool.parameters.get("properties", {})
328
+ if properties:
329
+ docstring_parts.append("\nParameters:")
330
+ for param_name, param_schema in properties.items():
331
+ param_type = param_schema.get("type", "string")
332
+ param_desc = param_schema.get("description", "")
333
+
334
+ param_line = f"{param_name} ({param_type})"
335
+
336
+ # Add enum information
337
+ if "enum" in param_schema:
338
+ enum_values = map(str, param_schema["enum"])
339
+ param_line += f" - One of: {', '.join(enum_values)}"
340
+
341
+ # Add default if available
342
+ if "default" in param_schema:
343
+ param_line += f" (default: {param_schema['default']})"
344
+
345
+ if param_desc:
346
+ param_line += f": {param_desc}"
347
+
348
+ docstring_parts.append(param_line)
349
+
350
+ dynamic_tool_func.__doc__ = "\n".join(docstring_parts)
351
+
352
+ annotations = {"return": str} # Set return type annotation
353
+
354
+ # Create parameter annotations for better type checking
355
+ sig_params = []
356
+ for param_name, param_schema in properties.items():
357
+ # For enum parameters, we could create a custom type, but for simplicity use str
358
+ if "enum" in param_schema:
359
+ param_type = str # MCP will handle validation
360
+ else:
361
+ param_type = self._schema_type_to_python_type(
362
+ param_schema.get("type", "string")
363
+ )
364
+
365
+ annotation_type = inspect.Parameter.empty
366
+ default = inspect.Parameter.empty
367
+
368
+ if param_name not in api_tool.parameters.get("required", []):
369
+ # Create parameter with default value
370
+ default = param_schema.get("default", None)
371
+ annotation_type = (
372
+ param_type if default is not None else Optional[param_type]
373
+ )
374
+
375
+ sig_params.append(
376
+ inspect.Parameter(
377
+ param_name,
378
+ inspect.Parameter.KEYWORD_ONLY,
379
+ annotation=annotation_type,
380
+ default=default,
381
+ )
382
+ )
383
+ annotations[param_name] = param_type
384
+
385
+ # Create new signature
386
+ dynamic_tool_func.__signature__ = inspect.Signature(sig_params)
387
+ dynamic_tool_func.__annotations__ = annotations
388
+
389
+ # Register with MCP server - this uses the decorator approach
390
+ self.mcp.tool(
391
+ annotations=types.ToolAnnotations(
392
+ destructiveHint=api_tool.is_destructive,
393
+ readOnlyHint=api_tool.is_read_only,
394
+ )
395
+ )(dynamic_tool_func)
396
+
397
+ def _schema_type_to_python_type(self, schema_type: str):
398
+ """Convert OpenAPI schema type to Python type"""
399
+ type_mapping = {
400
+ "string": str,
401
+ "integer": int,
402
+ "number": float,
403
+ "boolean": bool,
404
+ "array": list,
405
+ "object": dict,
406
+ }
407
+ return type_mapping.get(schema_type, str)
408
+
409
+ def _get_additional_headers(self):
410
+ headers = {}
411
+ if "X-Api-Key" in self.api_config.api_key:
412
+ headers["X-Api-Key"] = self.api_config.api_key["X-Api-Key"]
413
+
414
+ if self.api_config.headers:
415
+ headers.update(self.api_config.headers)
416
+
417
+ return headers
418
+
419
+ def _get_request_params(
420
+ self, url: str, tool: OpenAPITool, arguments: Dict[str, Any]
421
+ ):
422
+ """Get params to use for HTTP request based on tool arguments"""
423
+
424
+ query_params = {}
425
+ body_params = {}
426
+
427
+ # Separate parameters by type based on OpenAPI spec
428
+ properties = tool.parameters.get("properties", {})
429
+ validated_arguments = {}
430
+
431
+ for key, value in arguments.items():
432
+ if key in properties:
433
+ param_schema = properties[key]
434
+
435
+ # Skip None values for optional parameters
436
+ if value is None:
437
+ if "default" in param_schema:
438
+ validated_arguments[key] = param_schema["default"]
439
+ continue
440
+
441
+ # Validate enum values
442
+ if "enum" in param_schema:
443
+ if value not in param_schema["enum"]:
444
+ allowed_values = ", ".join(param_schema["enum"])
445
+ raise ValueError(
446
+ f"Invalid value '{value}' for parameter '{key}'. Allowed values: {allowed_values}"
447
+ )
448
+
449
+ validated_arguments[key] = value
450
+ else:
451
+ validated_arguments[key] = value
452
+
453
+ for key, value in validated_arguments.items():
454
+ if key in properties:
455
+ if "{" + key + "}" in url:
456
+ # This is a parameter as part of the URL, so replace it
457
+ url = url.replace("{" + key + "}", str(value))
458
+ elif tool.method in ["GET", "DELETE"]:
459
+ # Query parameter for GET/DELETE
460
+ query_params[key] = value
461
+ else:
462
+ # Body parameter for POST/PUT/PATCH
463
+ body_params[key] = value
464
+
465
+ return url, query_params, body_params
466
+
467
+ async def _execute_api_call(
468
+ self, tool: OpenAPITool, arguments: Dict[str, Any]
469
+ ) -> str:
470
+ """Execute an API call based on tool definition"""
471
+
472
+ headers = self._get_additional_headers()
473
+ headers.update(
474
+ {
475
+ "Accept": "application/json",
476
+ }
477
+ )
478
+
479
+ http_client = create_mcp_http_client(headers=headers)
480
+
481
+ # Build URL with path parameters
482
+ url = tool.base_url + tool.path
483
+
484
+ try:
485
+ url, query_params, body_params = self._get_request_params(
486
+ url, tool, arguments
487
+ )
488
+ except ValueError as e:
489
+ return str(e)
490
+
491
+ if tool.query_filter:
492
+ parsed_simplified_filter = parse.parse_qs(tool.query_filter)
493
+ query_params.update(parsed_simplified_filter)
494
+
495
+ try:
496
+ # Make the API call
497
+ if tool.method == "GET":
498
+ response = await http_client.get(url, params=query_params)
499
+ elif tool.method == "POST":
500
+ response = await http_client.post(
501
+ url, json=body_params, params=query_params
502
+ )
503
+ elif tool.method == "PUT":
504
+ response = await http_client.put(
505
+ url, json=body_params, params=query_params
506
+ )
507
+ elif tool.method == "DELETE":
508
+ response = await http_client.delete(url, params=query_params)
509
+ elif tool.method == "PATCH":
510
+ response = await http_client.patch(
511
+ url, json=body_params, params=query_params
512
+ )
513
+ else:
514
+ # Unsupported method, shouldn't happen
515
+ return f"Unsupported HTTP method: {tool.method}"
516
+
517
+ response.raise_for_status()
518
+
519
+ # Return formatted response
520
+ result = response.json()
521
+ if self.use_toon:
522
+ return toon.encode(result)
523
+ return json.dumps(result, indent=2)
524
+
525
+ except (json.JSONDecodeError, toon.ToonEncodingError):
526
+ return response.text
527
+ except httpx.HTTPError as e:
528
+ return f"HTTP error: {str(e)}"
529
+ finally:
530
+ await http_client.aclose()
531
+
532
+ def _extract_parameters_from_schema(
533
+ self, schema: Dict[str, Any], param_in: str = "body"
534
+ ) -> Dict[str, Any]:
535
+ """Extract individual parameters from a resolved schema object"""
536
+
537
+ parameters = {}
538
+
539
+ if schema.get("type") == "object" and "properties" in schema:
540
+ for prop_name, prop_schema in schema["properties"].items():
541
+ enhanced_schema = {
542
+ **prop_schema,
543
+ "in": param_in,
544
+ "description": prop_schema.get("description", ""),
545
+ }
546
+
547
+ # Handle enum descriptions
548
+ if "enum" in prop_schema:
549
+ enhanced_schema["enum_description"] = self._format_enum_description(
550
+ prop_schema["enum"], prop_schema.get("description", "")
551
+ )
552
+
553
+ parameters[prop_name] = enhanced_schema
554
+
555
+ return parameters
556
+
557
+ def _extract_request_body_parameters(
558
+ self, request_body: Dict[str, Any]
559
+ ) -> Dict[str, Any]:
560
+ """Extract parameters from OpenAPI 3.0 request body with $ref resolution"""
561
+
562
+ parameters = {}
563
+ content = request_body.get("content", {})
564
+
565
+ # Handle JSON request body
566
+ if "application/json" in content:
567
+ json_schema = content["application/json"].get("schema", {})
568
+ resolved_schema = self._resolve_schema(json_schema)
569
+ parameters.update(
570
+ self._extract_parameters_from_schema(resolved_schema, "body")
571
+ )
572
+
573
+ # Handle form data
574
+ if "application/x-www-form-urlencoded" in content:
575
+ form_schema = content["application/x-www-form-urlencoded"].get("schema", {})
576
+ resolved_schema = self._resolve_schema(form_schema)
577
+ parameters.update(
578
+ self._extract_parameters_from_schema(resolved_schema, "form")
579
+ )
580
+
581
+ return parameters
582
+
583
+ def _extract_body_parameter(self, body_param: Dict[str, Any]) -> Dict[str, str]:
584
+ """Extract parameters from Swagger 2.0 body parameter with $ref resolution"""
585
+
586
+ if "schema" not in body_param:
587
+ return {}
588
+
589
+ # Resolve the schema reference
590
+ schema = self._resolve_schema(body_param["schema"])
591
+
592
+ return self._extract_parameters_from_schema(schema, "body")
593
+
594
+ def _create_tool_from_operation(
595
+ self,
596
+ method: str,
597
+ path: str,
598
+ operation: Dict[str, Any],
599
+ path_parameters: list,
600
+ base_url: str,
601
+ ) -> Optional[OpenAPITool]:
602
+ """Create a tool definition from an OpenAPI operation"""
603
+
604
+ # Generate operation ID
605
+ operation_id = operation.get("operationId")
606
+ if not operation_id:
607
+ operation_id = f"{method.lower()}{path.replace('/', '_').replace('{', '').replace('}', '')}"
608
+
609
+ # Clean up operation ID to be a valid Python function name
610
+ tool_name = operation_id.replace("-", "_").replace(".", "_").lower()
611
+
612
+ description = (
613
+ operation.get("summary")
614
+ or operation.get("description")
615
+ or f"{method} {path}"
616
+ )
617
+
618
+ # Extract parameters
619
+ parameters = {}
620
+ required_params = []
621
+
622
+ operation_params = operation.get("parameters", [])
623
+ all_parameters = operation_params + path_parameters
624
+
625
+ # Path and query parameters for swagger 2.0
626
+ for param in all_parameters:
627
+ if param.get("in") == "path" or param.get("in") == "query":
628
+ param_name = param["name"]
629
+
630
+ param_type = param.get("type", "string")
631
+ param_schema = param.get("schema", {"type": param_type})
632
+ enhanced_schema = {
633
+ **param_schema,
634
+ "description": param.get("description", ""),
635
+ "in": param.get("in"),
636
+ }
637
+
638
+ if "enum" in param_schema:
639
+ enhanced_schema["enum"] = param_schema["enum"]
640
+ enhanced_schema["enum_description"] = self._format_enum_description(
641
+ param_schema.get("enum", []), param.get("description", "")
642
+ )
643
+
644
+ parameters[param_name] = enhanced_schema
645
+
646
+ if param.get("required", False):
647
+ required_params.append(param["name"])
648
+ elif param.get("in") == "body":
649
+ body_params = self._extract_body_parameter(param)
650
+ parameters.update(body_params)
651
+ if param.get("required", False):
652
+ required_params.extend(body_params.keys())
653
+
654
+ # handle request body for openapi 3.0+
655
+ if method in ["POST", "PUT", "PATCH"] and "requestBody" in operation:
656
+ body_params = self._extract_request_body_parameters(
657
+ operation["requestBody"]
658
+ )
659
+ parameters.update(body_params)
660
+
661
+ if operation["requestBody"].get("required", True):
662
+ required_params.extend(body_params.keys())
663
+
664
+ simplified_query = operation.get("x-simplified")
665
+
666
+ # Create parameter schema for MCP
667
+ parameter_schema = {
668
+ "type": "object",
669
+ "properties": parameters,
670
+ "required": required_params,
671
+ }
672
+
673
+ return OpenAPITool(
674
+ name=tool_name,
675
+ description=description,
676
+ method=method,
677
+ path=path,
678
+ parameters=parameter_schema,
679
+ base_url=base_url,
680
+ query_filter=simplified_query,
681
+ is_destructive=self._is_tool_destructive(tool_name),
682
+ is_read_only=self._is_tool_read_only(tool_name),
683
+ )
684
+
685
+ def _format_enum_description(
686
+ self, enum_values: List[str], original_description: str
687
+ ) -> str:
688
+ """Format enum values for better tool descriptions"""
689
+
690
+ if not enum_values:
691
+ return original_description
692
+
693
+ enum_list = "\n".join([f" - {value}" for value in enum_values])
694
+
695
+ if original_description:
696
+ return f"{original_description}\n\nAllowed values:\n{enum_list}"
697
+
698
+ return f"Allowed values:\n{enum_list}"
699
+
700
+ def _resolve_schema_ref(self, ref_string: str) -> Dict[str, Any]:
701
+ """
702
+ Resolve a $ref reference to its actual schema definition
703
+
704
+ Args:
705
+ ref_string: The $ref string like "#/definitions/PackageCopyRequest"
706
+ spec: The full OpenAPI specification
707
+
708
+ Returns:
709
+ The resolved schema definition
710
+ """
711
+ if not ref_string.startswith("#/"):
712
+ raise ValueError(f"Only local references supported: {ref_string}")
713
+
714
+ if not self.spec:
715
+ raise ValueError("OpenAPI spec not loaded")
716
+
717
+ # Remove the '#/' prefix and split the path
718
+ path_parts = ref_string[2:].split("/")
719
+
720
+ # Navigate through the spec to find the definition
721
+ current = self.spec
722
+ for part in path_parts:
723
+ if part in current:
724
+ current = current[part]
725
+ else:
726
+ raise ValueError(f"Reference not found: {ref_string}")
727
+
728
+ return current
729
+
730
+ def _resolve_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
731
+ """
732
+ Recursively resolve a schema, handling $ref references
733
+ """
734
+ if "$ref" in schema:
735
+ # Resolve the reference
736
+ resolved = self._resolve_schema_ref(schema["$ref"])
737
+ # Recursively resolve the resolved schema in case it has more refs
738
+ return self._resolve_schema(resolved)
739
+
740
+ # Handle nested schemas
741
+ resolved_schema = schema.copy()
742
+
743
+ # Resolve properties in object schemas
744
+ if "properties" in schema:
745
+ resolved_schema["properties"] = {}
746
+ for prop_name, prop_schema in schema["properties"].items():
747
+ resolved_schema["properties"][prop_name] = self._resolve_schema(
748
+ prop_schema
749
+ )
750
+
751
+ # Resolve items in array schemas
752
+ if "items" in schema:
753
+ resolved_schema["items"] = self._resolve_schema(schema["items"])
754
+
755
+ # Resolve allOf, oneOf, anyOf
756
+ for key in ["allOf", "oneOf", "anyOf"]:
757
+ if key in schema:
758
+ resolved_schema[key] = [
759
+ self._resolve_schema(sub_schema) for sub_schema in schema[key]
760
+ ]
761
+
762
+ return resolved_schema
763
+
764
+ def run(self):
765
+ """Initialize and run the server"""
766
+ asyncio.run(self.load_openapi_spec())
767
+ try:
768
+ self.mcp.run(transport="stdio")
769
+ except asyncio.CancelledError:
770
+ print("Server shutdown requested")
771
+
772
+ def list_tools(self) -> Dict[str, OpenAPITool]:
773
+ """Initialize and return list of tools. Useful for debugging"""
774
+ asyncio.run(self.load_openapi_spec())
775
+ return self.tools
776
+
777
+ def list_groups(self) -> Dict[str, List[str]]:
778
+ """Initialize and return list of tool groups with their tools. Useful for debugging"""
779
+ asyncio.run(self.load_openapi_spec())
780
+
781
+ # Build a mapping of group -> list of tools
782
+ groups: Dict[str, List[str]] = {}
783
+
784
+ for tool_name in self.tools:
785
+ tool_groups = self._get_tool_groups(tool_name)
786
+ for group in tool_groups:
787
+ if group not in groups:
788
+ groups[group] = []
789
+ groups[group].append(tool_name)
790
+
791
+ # Sort groups by name and tools within each group
792
+ return {group: sorted(tools) for group, tools in sorted(groups.items())}