cloudsmith-cli 1.20.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.
Files changed (130) hide show
  1. cloudsmith_cli/__init__.py +10 -0
  2. cloudsmith_cli/__main__.py +8 -0
  3. cloudsmith_cli/cli/__init__.py +1 -0
  4. cloudsmith_cli/cli/command.py +160 -0
  5. cloudsmith_cli/cli/commands/__init__.py +33 -0
  6. cloudsmith_cli/cli/commands/auth.py +173 -0
  7. cloudsmith_cli/cli/commands/check.py +129 -0
  8. cloudsmith_cli/cli/commands/copy.py +98 -0
  9. cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
  10. cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
  11. cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
  12. cloudsmith_cli/cli/commands/delete.py +67 -0
  13. cloudsmith_cli/cli/commands/dependencies.py +108 -0
  14. cloudsmith_cli/cli/commands/docs.py +16 -0
  15. cloudsmith_cli/cli/commands/download.py +620 -0
  16. cloudsmith_cli/cli/commands/entitlements.py +819 -0
  17. cloudsmith_cli/cli/commands/help_.py +12 -0
  18. cloudsmith_cli/cli/commands/list_.py +317 -0
  19. cloudsmith_cli/cli/commands/login.py +99 -0
  20. cloudsmith_cli/cli/commands/logout.py +151 -0
  21. cloudsmith_cli/cli/commands/main.py +73 -0
  22. cloudsmith_cli/cli/commands/mcp.py +523 -0
  23. cloudsmith_cli/cli/commands/metadata.py +503 -0
  24. cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
  25. cloudsmith_cli/cli/commands/metrics/command.py +20 -0
  26. cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
  27. cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
  28. cloudsmith_cli/cli/commands/move.py +111 -0
  29. cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
  30. cloudsmith_cli/cli/commands/policy/command.py +20 -0
  31. cloudsmith_cli/cli/commands/policy/deny.py +248 -0
  32. cloudsmith_cli/cli/commands/policy/license.py +335 -0
  33. cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
  34. cloudsmith_cli/cli/commands/push.py +1323 -0
  35. cloudsmith_cli/cli/commands/quarantine.py +148 -0
  36. cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
  37. cloudsmith_cli/cli/commands/quota/command.py +20 -0
  38. cloudsmith_cli/cli/commands/quota/history.py +122 -0
  39. cloudsmith_cli/cli/commands/quota/quota.py +107 -0
  40. cloudsmith_cli/cli/commands/repos.py +330 -0
  41. cloudsmith_cli/cli/commands/resync.py +90 -0
  42. cloudsmith_cli/cli/commands/status.py +92 -0
  43. cloudsmith_cli/cli/commands/tags.py +375 -0
  44. cloudsmith_cli/cli/commands/tokens.py +318 -0
  45. cloudsmith_cli/cli/commands/upstream.py +479 -0
  46. cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
  47. cloudsmith_cli/cli/commands/whoami.py +188 -0
  48. cloudsmith_cli/cli/config.py +635 -0
  49. cloudsmith_cli/cli/decorators.py +624 -0
  50. cloudsmith_cli/cli/exceptions.py +215 -0
  51. cloudsmith_cli/cli/metadata_common.py +146 -0
  52. cloudsmith_cli/cli/saml.py +109 -0
  53. cloudsmith_cli/cli/table.py +59 -0
  54. cloudsmith_cli/cli/types.py +14 -0
  55. cloudsmith_cli/cli/utils.py +267 -0
  56. cloudsmith_cli/cli/validators.py +378 -0
  57. cloudsmith_cli/cli/webserver.py +263 -0
  58. cloudsmith_cli/core/__init__.py +1 -0
  59. cloudsmith_cli/core/api/__init__.py +1 -0
  60. cloudsmith_cli/core/api/distros.py +31 -0
  61. cloudsmith_cli/core/api/entitlements.py +130 -0
  62. cloudsmith_cli/core/api/exceptions.py +57 -0
  63. cloudsmith_cli/core/api/files.py +131 -0
  64. cloudsmith_cli/core/api/init.py +109 -0
  65. cloudsmith_cli/core/api/metadata.py +217 -0
  66. cloudsmith_cli/core/api/metrics.py +78 -0
  67. cloudsmith_cli/core/api/orgs.py +201 -0
  68. cloudsmith_cli/core/api/packages.py +309 -0
  69. cloudsmith_cli/core/api/quota.py +64 -0
  70. cloudsmith_cli/core/api/rates.py +28 -0
  71. cloudsmith_cli/core/api/repos.py +81 -0
  72. cloudsmith_cli/core/api/status.py +27 -0
  73. cloudsmith_cli/core/api/upstreams.py +72 -0
  74. cloudsmith_cli/core/api/user.py +109 -0
  75. cloudsmith_cli/core/api/version.py +15 -0
  76. cloudsmith_cli/core/api/vulnerabilities.py +230 -0
  77. cloudsmith_cli/core/cache_utils.py +160 -0
  78. cloudsmith_cli/core/config.py +140 -0
  79. cloudsmith_cli/core/credentials/__init__.py +0 -0
  80. cloudsmith_cli/core/credentials/chain.py +69 -0
  81. cloudsmith_cli/core/credentials/models.py +44 -0
  82. cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
  83. cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
  84. cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
  85. cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
  86. cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
  87. cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
  88. cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
  89. cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
  90. cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
  91. cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
  92. cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
  93. cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
  94. cloudsmith_cli/core/credentials/provider.py +17 -0
  95. cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
  96. cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
  97. cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
  98. cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
  99. cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
  100. cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
  101. cloudsmith_cli/core/download.py +594 -0
  102. cloudsmith_cli/core/keyring.py +171 -0
  103. cloudsmith_cli/core/mcp/__init__.py +0 -0
  104. cloudsmith_cli/core/mcp/data.py +17 -0
  105. cloudsmith_cli/core/mcp/server.py +786 -0
  106. cloudsmith_cli/core/pagination.py +131 -0
  107. cloudsmith_cli/core/ratelimits.py +88 -0
  108. cloudsmith_cli/core/rest.py +255 -0
  109. cloudsmith_cli/core/utils.py +95 -0
  110. cloudsmith_cli/core/version.py +20 -0
  111. cloudsmith_cli/credential_helpers/__init__.py +7 -0
  112. cloudsmith_cli/credential_helpers/backends.py +41 -0
  113. cloudsmith_cli/credential_helpers/common.py +111 -0
  114. cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
  115. cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
  116. cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
  117. cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
  118. cloudsmith_cli/credential_helpers/launchers.py +175 -0
  119. cloudsmith_cli/data/VERSION +1 -0
  120. cloudsmith_cli/data/config.ini +23 -0
  121. cloudsmith_cli/data/credentials.ini +14 -0
  122. cloudsmith_cli/templates/__init__.py +3 -0
  123. cloudsmith_cli/templates/auth_error.html +45 -0
  124. cloudsmith_cli/templates/auth_success.html +37 -0
  125. cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
  126. cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
  127. cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
  128. cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
  129. cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
  130. cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,786 @@
1
+ import asyncio
2
+ import copy
3
+ import inspect
4
+ import json
5
+ from typing import Any, Optional
6
+ from urllib import parse
7
+
8
+ import cloudsmith_api
9
+ import httpx
10
+ import 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: list[str] | None = None,
184
+ allowed_tools: list[str] | None = 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
+ response = await http_client.get(spec_url)
213
+ response.raise_for_status()
214
+ self.spec = response.json()
215
+ await self._generate_tools_from_spec()
216
+
217
+ def _get_tool_groups(self, tool_name: str) -> list[str]:
218
+ """
219
+ Extract all hierarchical group names from a tool name, excluding action suffixes.
220
+
221
+ Examples:
222
+ webhooks_create -> ['webhooks']
223
+ repos_upstream_swift_list -> ['repos', 'repos_upstream', 'repos_upstream_swift']
224
+ vulnerabilities_read -> ['vulnerabilities']
225
+ workspaces_policies_actions_partial_update -> ['workspaces', 'workspaces_policies']
226
+ repos_upstream_huggingface_partial_update -> ['repos', 'repos_upstream', 'repos_upstream_huggingface']
227
+ """
228
+ groups = []
229
+ parts = tool_name.split("_")
230
+
231
+ # Determine how many parts belong to the action suffix
232
+ # Sort by length descending to match longest suffixes first
233
+ sorted_suffixes = sorted(
234
+ TOOL_ACTION_SUFFIXES, key=lambda x: len(x.split("_")), reverse=True
235
+ )
236
+
237
+ action_parts_count = 0
238
+ for action_suffix in sorted_suffixes:
239
+ action_suffix_parts = action_suffix.split("_")
240
+ if len(parts) >= len(action_suffix_parts):
241
+ # Check if the end of the tool name matches this action suffix
242
+ if parts[-len(action_suffix_parts) :] == action_suffix_parts:
243
+ action_parts_count = len(action_suffix_parts)
244
+ break
245
+
246
+ # If no action suffix found, treat the last part as the action
247
+ if action_parts_count == 0:
248
+ action_parts_count = 1
249
+
250
+ # Build hierarchical groups by progressively adding parts, excluding action suffix
251
+ resource_parts = (
252
+ parts[:-action_parts_count] if action_parts_count > 0 else parts
253
+ )
254
+ for i in range(1, len(resource_parts) + 1):
255
+ group = "_".join(resource_parts[:i])
256
+ groups.append(group)
257
+
258
+ return groups
259
+
260
+ def _is_tool_destructive(self, tool_name: str) -> bool:
261
+ return any(suffix in tool_name for suffix in TOOL_DELETE_SUFFIXES)
262
+
263
+ def _is_tool_read_only(self, tool_name: str) -> bool:
264
+ return any(suffix in tool_name for suffix in TOOL_READ_ONLY_SUFFIXES)
265
+
266
+ def _is_tool_allowed(self, tool_name: str) -> bool:
267
+ """Check if a tool is allowed based on user configuration"""
268
+
269
+ if self.force_all_tools:
270
+ return True
271
+
272
+ # Check if tool is destructive and destructive tools are disabled
273
+ if not self.allow_destructive_tools and self._is_tool_destructive(tool_name):
274
+ return False
275
+
276
+ tool_groups = self._get_tool_groups(tool_name)
277
+
278
+ # If user provided their own list of allowed tools or tool groups
279
+ if len(self.allowed_tools) > 0 or len(self.allowed_tool_groups) > 0:
280
+ allowed_tool_group = bool(set(tool_groups) & set(self.allowed_tool_groups))
281
+ allowed_tool = tool_name in self.allowed_tools
282
+ return allowed_tool or allowed_tool_group
283
+
284
+ # Otherwise disable all categories in the default list
285
+ return not any(group in DEFAULT_DISABLED_CATEGORIES for group in tool_groups)
286
+
287
+ async def _generate_tools_from_spec(self):
288
+ """Generate MCP tools from OpenAPI specification"""
289
+
290
+ if not self.spec:
291
+ raise ValueError("OpenAPI spec not loaded")
292
+
293
+ # Parse paths and generate tools
294
+ for path, path_item in self.spec.get("paths", {}).items():
295
+ for method, operation in path_item.items():
296
+ path_parameters = path_item.get("parameters", [])
297
+
298
+ if method.lower() in ALLOWED_METHODS:
299
+ tool = self._create_tool_from_operation(
300
+ method.upper(),
301
+ path,
302
+ operation,
303
+ path_parameters,
304
+ self.api_base_url,
305
+ )
306
+ if tool and self._is_tool_allowed(tool.name):
307
+ self.tools[tool.name] = tool
308
+ self._register_dynamic_tool(tool)
309
+
310
+ def _register_dynamic_tool(self, api_tool: OpenAPITool):
311
+ """Register a single tool dynamically with the MCP server"""
312
+
313
+ # Create the tool function dynamically
314
+ async def dynamic_tool_func(**kwargs) -> str:
315
+ return await self._execute_api_call(api_tool, kwargs)
316
+
317
+ # Set function metadata for MCP
318
+ dynamic_tool_func.__name__ = api_tool.name
319
+
320
+ docstring_parts = [api_tool.description]
321
+ properties = api_tool.parameters.get("properties", {})
322
+ if properties:
323
+ docstring_parts.append("\nParameters:")
324
+ for param_name, param_schema in properties.items():
325
+ param_type = param_schema.get("type", "string")
326
+ param_desc = param_schema.get("description", "")
327
+
328
+ param_line = f"{param_name} ({param_type})"
329
+
330
+ # Add enum information
331
+ if "enum" in param_schema:
332
+ enum_values = map(str, param_schema["enum"])
333
+ param_line += f" - One of: {', '.join(enum_values)}"
334
+
335
+ # Add default if available
336
+ if "default" in param_schema:
337
+ param_line += f" (default: {param_schema['default']})"
338
+
339
+ if param_desc:
340
+ param_line += f": {param_desc}"
341
+
342
+ docstring_parts.append(param_line)
343
+
344
+ dynamic_tool_func.__doc__ = "\n".join(docstring_parts)
345
+
346
+ annotations = {"return": str} # Set return type annotation
347
+
348
+ # Create parameter annotations for better type checking
349
+ sig_params = []
350
+ for param_name, param_schema in properties.items():
351
+ # For enum parameters, we could create a custom type, but for simplicity use str
352
+ if "enum" in param_schema:
353
+ param_type = str # MCP will handle validation
354
+ else:
355
+ param_type = self._schema_type_to_python_type(
356
+ param_schema.get("type", "string")
357
+ )
358
+
359
+ annotation_type = inspect.Parameter.empty
360
+ default = inspect.Parameter.empty
361
+
362
+ if param_name not in api_tool.parameters.get("required", []):
363
+ # Create parameter with default value
364
+ default = param_schema.get("default", None)
365
+ annotation_type = (
366
+ param_type if default is not None else Optional[param_type]
367
+ )
368
+
369
+ sig_params.append(
370
+ inspect.Parameter(
371
+ param_name,
372
+ inspect.Parameter.KEYWORD_ONLY,
373
+ annotation=annotation_type,
374
+ default=default,
375
+ )
376
+ )
377
+ annotations[param_name] = param_type
378
+
379
+ # Create new signature
380
+ dynamic_tool_func.__signature__ = inspect.Signature(sig_params)
381
+ dynamic_tool_func.__annotations__ = annotations
382
+
383
+ # Register with MCP server - this uses the decorator approach
384
+ self.mcp.tool(
385
+ annotations=types.ToolAnnotations(
386
+ destructiveHint=api_tool.is_destructive,
387
+ readOnlyHint=api_tool.is_read_only,
388
+ )
389
+ )(dynamic_tool_func)
390
+
391
+ def _schema_type_to_python_type(self, schema_type: str):
392
+ """Convert OpenAPI schema type to Python type"""
393
+ type_mapping = {
394
+ "string": str,
395
+ "integer": int,
396
+ "number": float,
397
+ "boolean": bool,
398
+ "array": list,
399
+ "object": dict,
400
+ }
401
+ return type_mapping.get(schema_type, str)
402
+
403
+ def _get_additional_headers(self):
404
+ headers = {}
405
+ if "X-Api-Key" in self.api_config.api_key:
406
+ headers["X-Api-Key"] = self.api_config.api_key["X-Api-Key"]
407
+
408
+ if self.api_config.headers:
409
+ headers.update(self.api_config.headers)
410
+
411
+ return headers
412
+
413
+ def _get_request_params(
414
+ self, url: str, tool: OpenAPITool, arguments: dict[str, Any]
415
+ ):
416
+ """Get params to use for HTTP request based on tool arguments"""
417
+
418
+ query_params = {}
419
+ body_params = {}
420
+
421
+ # Separate parameters by type based on OpenAPI spec
422
+ properties = tool.parameters.get("properties", {})
423
+ validated_arguments = {}
424
+
425
+ for key, value in arguments.items():
426
+ if key in properties:
427
+ param_schema = properties[key]
428
+
429
+ # Skip None values for optional parameters
430
+ if value is None:
431
+ if "default" in param_schema:
432
+ validated_arguments[key] = param_schema["default"]
433
+ continue
434
+
435
+ # Validate enum values
436
+ if "enum" in param_schema:
437
+ if value not in param_schema["enum"]:
438
+ allowed_values = ", ".join(param_schema["enum"])
439
+ raise ValueError(
440
+ f"Invalid value '{value}' for parameter '{key}'. Allowed values: {allowed_values}"
441
+ )
442
+
443
+ validated_arguments[key] = value
444
+ else:
445
+ validated_arguments[key] = value
446
+
447
+ for key, value in validated_arguments.items():
448
+ if key in properties:
449
+ if "{" + key + "}" in url:
450
+ # This is a parameter as part of the URL, so replace it
451
+ url = url.replace("{" + key + "}", str(value))
452
+ elif tool.method in ["GET", "DELETE"]:
453
+ # Query parameter for GET/DELETE
454
+ query_params[key] = value
455
+ else:
456
+ # Body parameter for POST/PUT/PATCH
457
+ body_params[key] = value
458
+
459
+ return url, query_params, body_params
460
+
461
+ async def _execute_api_call(
462
+ self, tool: OpenAPITool, arguments: dict[str, Any]
463
+ ) -> str:
464
+ """Execute an API call based on tool definition"""
465
+
466
+ headers = self._get_additional_headers()
467
+ headers.update(
468
+ {
469
+ "Accept": "application/json",
470
+ }
471
+ )
472
+
473
+ http_client = create_mcp_http_client(headers=headers)
474
+
475
+ # Build URL with path parameters
476
+ url = tool.base_url + tool.path
477
+
478
+ try:
479
+ url, query_params, body_params = self._get_request_params(
480
+ url, tool, arguments
481
+ )
482
+ except ValueError as e:
483
+ return str(e)
484
+
485
+ if tool.query_filter:
486
+ parsed_simplified_filter = parse.parse_qs(tool.query_filter)
487
+ query_params.update(parsed_simplified_filter)
488
+
489
+ try:
490
+ # Make the API call
491
+ if tool.method == "GET":
492
+ response = await http_client.get(url, params=query_params)
493
+ elif tool.method == "POST":
494
+ response = await http_client.post(
495
+ url, json=body_params, params=query_params
496
+ )
497
+ elif tool.method == "PUT":
498
+ response = await http_client.put(
499
+ url, json=body_params, params=query_params
500
+ )
501
+ elif tool.method == "DELETE":
502
+ response = await http_client.delete(url, params=query_params)
503
+ elif tool.method == "PATCH":
504
+ response = await http_client.patch(
505
+ url, json=body_params, params=query_params
506
+ )
507
+ else:
508
+ # Unsupported method, shouldn't happen
509
+ return f"Unsupported HTTP method: {tool.method}"
510
+
511
+ response.raise_for_status()
512
+
513
+ # Return formatted response
514
+ result = response.json()
515
+ if self.use_toon:
516
+ return toon.encode(result)
517
+ return json.dumps(result, indent=2)
518
+
519
+ except (json.JSONDecodeError, toon.ToonDecodeError):
520
+ return response.text
521
+ except httpx.HTTPError as e:
522
+ return f"HTTP error: {str(e)}"
523
+ finally:
524
+ await http_client.aclose()
525
+
526
+ def _extract_parameters_from_schema(
527
+ self, schema: dict[str, Any], param_in: str = "body"
528
+ ) -> dict[str, Any]:
529
+ """Extract individual parameters from a resolved schema object"""
530
+
531
+ parameters = {}
532
+
533
+ if schema.get("type") == "object" and "properties" in schema:
534
+ for prop_name, prop_schema in schema["properties"].items():
535
+ enhanced_schema = {
536
+ **prop_schema,
537
+ "in": param_in,
538
+ "description": prop_schema.get("description", ""),
539
+ }
540
+
541
+ # Handle enum descriptions
542
+ if "enum" in prop_schema:
543
+ enhanced_schema["enum_description"] = self._format_enum_description(
544
+ prop_schema["enum"], prop_schema.get("description", "")
545
+ )
546
+
547
+ parameters[prop_name] = enhanced_schema
548
+
549
+ return parameters
550
+
551
+ def _extract_request_body_parameters(
552
+ self, request_body: dict[str, Any]
553
+ ) -> dict[str, Any]:
554
+ """Extract parameters from OpenAPI 3.0 request body with $ref resolution"""
555
+
556
+ parameters = {}
557
+ content = request_body.get("content", {})
558
+
559
+ # Handle JSON request body
560
+ if "application/json" in content:
561
+ json_schema = content["application/json"].get("schema", {})
562
+ resolved_schema = self._resolve_schema(json_schema)
563
+ parameters.update(
564
+ self._extract_parameters_from_schema(resolved_schema, "body")
565
+ )
566
+
567
+ # Handle form data
568
+ if "application/x-www-form-urlencoded" in content:
569
+ form_schema = content["application/x-www-form-urlencoded"].get("schema", {})
570
+ resolved_schema = self._resolve_schema(form_schema)
571
+ parameters.update(
572
+ self._extract_parameters_from_schema(resolved_schema, "form")
573
+ )
574
+
575
+ return parameters
576
+
577
+ def _extract_body_parameter(self, body_param: dict[str, Any]) -> dict[str, str]:
578
+ """Extract parameters from Swagger 2.0 body parameter with $ref resolution"""
579
+
580
+ if "schema" not in body_param:
581
+ return {}
582
+
583
+ # Resolve the schema reference
584
+ schema = self._resolve_schema(body_param["schema"])
585
+
586
+ return self._extract_parameters_from_schema(schema, "body")
587
+
588
+ def _create_tool_from_operation(
589
+ self,
590
+ method: str,
591
+ path: str,
592
+ operation: dict[str, Any],
593
+ path_parameters: list,
594
+ base_url: str,
595
+ ) -> OpenAPITool | None:
596
+ """Create a tool definition from an OpenAPI operation"""
597
+
598
+ # Generate operation ID
599
+ operation_id = operation.get("operationId")
600
+ if not operation_id:
601
+ operation_id = f"{method.lower()}{path.replace('/', '_').replace('{', '').replace('}', '')}"
602
+
603
+ # Clean up operation ID to be a valid Python function name
604
+ tool_name = operation_id.replace("-", "_").replace(".", "_").lower()
605
+
606
+ description = (
607
+ operation.get("summary")
608
+ or operation.get("description")
609
+ or f"{method} {path}"
610
+ )
611
+
612
+ # Extract parameters
613
+ parameters = {}
614
+ required_params = []
615
+
616
+ operation_params = operation.get("parameters", [])
617
+ all_parameters = operation_params + path_parameters
618
+
619
+ # Path and query parameters for swagger 2.0
620
+ for param in all_parameters:
621
+ if param.get("in") == "path" or param.get("in") == "query":
622
+ param_name = param["name"]
623
+
624
+ param_type = param.get("type", "string")
625
+ param_schema = param.get("schema", {"type": param_type})
626
+ enhanced_schema = {
627
+ **param_schema,
628
+ "description": param.get("description", ""),
629
+ "in": param.get("in"),
630
+ }
631
+
632
+ if "enum" in param_schema:
633
+ enhanced_schema["enum"] = param_schema["enum"]
634
+ enhanced_schema["enum_description"] = self._format_enum_description(
635
+ param_schema.get("enum", []), param.get("description", "")
636
+ )
637
+
638
+ parameters[param_name] = enhanced_schema
639
+
640
+ if param.get("required", False):
641
+ required_params.append(param["name"])
642
+ elif param.get("in") == "body":
643
+ body_params = self._extract_body_parameter(param)
644
+ parameters.update(body_params)
645
+ if param.get("required", False):
646
+ required_params.extend(body_params.keys())
647
+
648
+ # handle request body for openapi 3.0+
649
+ if method in ["POST", "PUT", "PATCH"] and "requestBody" in operation:
650
+ body_params = self._extract_request_body_parameters(
651
+ operation["requestBody"]
652
+ )
653
+ parameters.update(body_params)
654
+
655
+ if operation["requestBody"].get("required", True):
656
+ required_params.extend(body_params.keys())
657
+
658
+ simplified_query = operation.get("x-simplified")
659
+
660
+ # Create parameter schema for MCP
661
+ parameter_schema = {
662
+ "type": "object",
663
+ "properties": parameters,
664
+ "required": required_params,
665
+ }
666
+
667
+ return OpenAPITool(
668
+ name=tool_name,
669
+ description=description,
670
+ method=method,
671
+ path=path,
672
+ parameters=parameter_schema,
673
+ base_url=base_url,
674
+ query_filter=simplified_query,
675
+ is_destructive=self._is_tool_destructive(tool_name),
676
+ is_read_only=self._is_tool_read_only(tool_name),
677
+ )
678
+
679
+ def _format_enum_description(
680
+ self, enum_values: list[str], original_description: str
681
+ ) -> str:
682
+ """Format enum values for better tool descriptions"""
683
+
684
+ if not enum_values:
685
+ return original_description
686
+
687
+ enum_list = "\n".join([f" - {value}" for value in enum_values])
688
+
689
+ if original_description:
690
+ return f"{original_description}\n\nAllowed values:\n{enum_list}"
691
+
692
+ return f"Allowed values:\n{enum_list}"
693
+
694
+ def _resolve_schema_ref(self, ref_string: str) -> dict[str, Any]:
695
+ """
696
+ Resolve a $ref reference to its actual schema definition
697
+
698
+ Args:
699
+ ref_string: The $ref string like "#/definitions/PackageCopyRequest"
700
+ spec: The full OpenAPI specification
701
+
702
+ Returns:
703
+ The resolved schema definition
704
+ """
705
+ if not ref_string.startswith("#/"):
706
+ raise ValueError(f"Only local references supported: {ref_string}")
707
+
708
+ if not self.spec:
709
+ raise ValueError("OpenAPI spec not loaded")
710
+
711
+ # Remove the '#/' prefix and split the path
712
+ path_parts = ref_string[2:].split("/")
713
+
714
+ # Navigate through the spec to find the definition
715
+ current = self.spec
716
+ for part in path_parts:
717
+ if part in current:
718
+ current = current[part]
719
+ else:
720
+ raise ValueError(f"Reference not found: {ref_string}")
721
+
722
+ return current
723
+
724
+ def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
725
+ """
726
+ Recursively resolve a schema, handling $ref references
727
+ """
728
+ if "$ref" in schema:
729
+ # Resolve the reference
730
+ resolved = self._resolve_schema_ref(schema["$ref"])
731
+ # Recursively resolve the resolved schema in case it has more refs
732
+ return self._resolve_schema(resolved)
733
+
734
+ # Handle nested schemas
735
+ resolved_schema = schema.copy()
736
+
737
+ # Resolve properties in object schemas
738
+ if "properties" in schema:
739
+ resolved_schema["properties"] = {}
740
+ for prop_name, prop_schema in schema["properties"].items():
741
+ resolved_schema["properties"][prop_name] = self._resolve_schema(
742
+ prop_schema
743
+ )
744
+
745
+ # Resolve items in array schemas
746
+ if "items" in schema:
747
+ resolved_schema["items"] = self._resolve_schema(schema["items"])
748
+
749
+ # Resolve allOf, oneOf, anyOf
750
+ for key in ["allOf", "oneOf", "anyOf"]:
751
+ if key in schema:
752
+ resolved_schema[key] = [
753
+ self._resolve_schema(sub_schema) for sub_schema in schema[key]
754
+ ]
755
+
756
+ return resolved_schema
757
+
758
+ def run(self):
759
+ """Initialize and run the server"""
760
+ asyncio.run(self.load_openapi_spec())
761
+ try:
762
+ self.mcp.run(transport="stdio")
763
+ except asyncio.CancelledError:
764
+ print("Server shutdown requested")
765
+
766
+ def list_tools(self) -> dict[str, OpenAPITool]:
767
+ """Initialize and return list of tools. Useful for debugging"""
768
+ asyncio.run(self.load_openapi_spec())
769
+ return self.tools
770
+
771
+ def list_groups(self) -> dict[str, list[str]]:
772
+ """Initialize and return list of tool groups with their tools. Useful for debugging"""
773
+ asyncio.run(self.load_openapi_spec())
774
+
775
+ # Build a mapping of group -> list of tools
776
+ groups: dict[str, list[str]] = {}
777
+
778
+ for tool_name in self.tools:
779
+ tool_groups = self._get_tool_groups(tool_name)
780
+ for group in tool_groups:
781
+ if group not in groups:
782
+ groups[group] = []
783
+ groups[group].append(tool_name)
784
+
785
+ # Sort groups by name and tools within each group
786
+ return {group: sorted(tools) for group, tools in sorted(groups.items())}