cloudsmith-cli 1.11.0__py2.py3-none-any.whl → 1.11.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.
- cloudsmith_cli/cli/commands/__init__.py +0 -1
- cloudsmith_cli/cli/config.py +0 -31
- cloudsmith_cli/cli/decorators.py +3 -47
- cloudsmith_cli/data/VERSION +1 -1
- {cloudsmith_cli-1.11.0.dist-info → cloudsmith_cli-1.11.1.dist-info}/METADATA +2 -4
- {cloudsmith_cli-1.11.0.dist-info → cloudsmith_cli-1.11.1.dist-info}/RECORD +10 -15
- cloudsmith_cli/cli/commands/mcp.py +0 -424
- cloudsmith_cli/cli/tests/commands/test_mcp.py +0 -357
- cloudsmith_cli/core/mcp/__init__.py +0 -0
- cloudsmith_cli/core/mcp/data.py +0 -17
- cloudsmith_cli/core/mcp/server.py +0 -779
- {cloudsmith_cli-1.11.0.dist-info → cloudsmith_cli-1.11.1.dist-info}/WHEEL +0 -0
- {cloudsmith_cli-1.11.0.dist-info → cloudsmith_cli-1.11.1.dist-info}/entry_points.txt +0 -0
- {cloudsmith_cli-1.11.0.dist-info → cloudsmith_cli-1.11.1.dist-info}/licenses/LICENSE +0 -0
- {cloudsmith_cli-1.11.0.dist-info → cloudsmith_cli-1.11.1.dist-info}/top_level.txt +0 -0
|
@@ -1,779 +0,0 @@
|
|
|
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
|
-
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
|
-
return f"Invalid value '{value}' for parameter '{key}'. Allowed values: {allowed_values}"
|
|
440
|
-
|
|
441
|
-
validated_arguments[key] = value
|
|
442
|
-
else:
|
|
443
|
-
validated_arguments[key] = value
|
|
444
|
-
|
|
445
|
-
for key, value in validated_arguments.items():
|
|
446
|
-
if key in properties:
|
|
447
|
-
if "{" + key + "}" in url:
|
|
448
|
-
# This is a parameter as part of the URL, so replace it
|
|
449
|
-
url = url.replace("{" + key + "}", str(value))
|
|
450
|
-
elif tool.method in ["GET", "DELETE"]:
|
|
451
|
-
# Query parameter for GET/DELETE
|
|
452
|
-
query_params[key] = value
|
|
453
|
-
else:
|
|
454
|
-
# Body parameter for POST/PUT/PATCH
|
|
455
|
-
body_params[key] = value
|
|
456
|
-
|
|
457
|
-
return url, query_params, body_params
|
|
458
|
-
|
|
459
|
-
async def _execute_api_call(
|
|
460
|
-
self, tool: OpenAPITool, arguments: Dict[str, Any]
|
|
461
|
-
) -> str:
|
|
462
|
-
"""Execute an API call based on tool definition"""
|
|
463
|
-
|
|
464
|
-
headers = self._get_additional_headers()
|
|
465
|
-
headers.update(
|
|
466
|
-
{
|
|
467
|
-
"Accept": "application/json",
|
|
468
|
-
}
|
|
469
|
-
)
|
|
470
|
-
|
|
471
|
-
http_client = create_mcp_http_client(headers=headers)
|
|
472
|
-
|
|
473
|
-
# Build URL with path parameters
|
|
474
|
-
url = tool.base_url + tool.path
|
|
475
|
-
|
|
476
|
-
url, query_params, body_params = self._get_request_params(url, tool, arguments)
|
|
477
|
-
|
|
478
|
-
if tool.query_filter:
|
|
479
|
-
parsed_simplified_filter = parse.parse_qs(tool.query_filter)
|
|
480
|
-
query_params.update(parsed_simplified_filter)
|
|
481
|
-
|
|
482
|
-
try:
|
|
483
|
-
# Make the API call
|
|
484
|
-
if tool.method == "GET":
|
|
485
|
-
response = await http_client.get(url, params=query_params)
|
|
486
|
-
elif tool.method == "POST":
|
|
487
|
-
response = await http_client.post(
|
|
488
|
-
url, json=body_params, params=query_params
|
|
489
|
-
)
|
|
490
|
-
elif tool.method == "PUT":
|
|
491
|
-
response = await http_client.put(
|
|
492
|
-
url, json=body_params, params=query_params
|
|
493
|
-
)
|
|
494
|
-
elif tool.method == "DELETE":
|
|
495
|
-
response = await http_client.delete(url, params=query_params)
|
|
496
|
-
elif tool.method == "PATCH":
|
|
497
|
-
response = await http_client.patch(
|
|
498
|
-
url, json=body_params, params=query_params
|
|
499
|
-
)
|
|
500
|
-
else:
|
|
501
|
-
# Unsupported method, shouldn't happen
|
|
502
|
-
return f"Unsupported HTTP method: {tool.method}"
|
|
503
|
-
|
|
504
|
-
response.raise_for_status()
|
|
505
|
-
|
|
506
|
-
# Return formatted response
|
|
507
|
-
result = response.json()
|
|
508
|
-
if self.use_toon:
|
|
509
|
-
return toon.encode(result)
|
|
510
|
-
return json.dumps(result, indent=2)
|
|
511
|
-
|
|
512
|
-
except (json.JSONDecodeError, toon.ToonEncodingError):
|
|
513
|
-
return response.text
|
|
514
|
-
except httpx.HTTPError as e:
|
|
515
|
-
return f"HTTP error: {str(e)}"
|
|
516
|
-
finally:
|
|
517
|
-
await http_client.aclose()
|
|
518
|
-
|
|
519
|
-
def _extract_parameters_from_schema(
|
|
520
|
-
self, schema: Dict[str, Any], param_in: str = "body"
|
|
521
|
-
) -> Dict[str, Any]:
|
|
522
|
-
"""Extract individual parameters from a resolved schema object"""
|
|
523
|
-
|
|
524
|
-
parameters = {}
|
|
525
|
-
|
|
526
|
-
if schema.get("type") == "object" and "properties" in schema:
|
|
527
|
-
for prop_name, prop_schema in schema["properties"].items():
|
|
528
|
-
enhanced_schema = {
|
|
529
|
-
**prop_schema,
|
|
530
|
-
"in": param_in,
|
|
531
|
-
"description": prop_schema.get("description", ""),
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
# Handle enum descriptions
|
|
535
|
-
if "enum" in prop_schema:
|
|
536
|
-
enhanced_schema["enum_description"] = self._format_enum_description(
|
|
537
|
-
prop_schema["enum"], prop_schema.get("description", "")
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
parameters[prop_name] = enhanced_schema
|
|
541
|
-
|
|
542
|
-
return parameters
|
|
543
|
-
|
|
544
|
-
def _extract_request_body_parameters(
|
|
545
|
-
self, request_body: Dict[str, Any]
|
|
546
|
-
) -> Dict[str, Any]:
|
|
547
|
-
"""Extract parameters from OpenAPI 3.0 request body with $ref resolution"""
|
|
548
|
-
|
|
549
|
-
parameters = {}
|
|
550
|
-
content = request_body.get("content", {})
|
|
551
|
-
|
|
552
|
-
# Handle JSON request body
|
|
553
|
-
if "application/json" in content:
|
|
554
|
-
json_schema = content["application/json"].get("schema", {})
|
|
555
|
-
resolved_schema = self._resolve_schema(json_schema)
|
|
556
|
-
parameters.update(
|
|
557
|
-
self._extract_parameters_from_schema(resolved_schema, "body")
|
|
558
|
-
)
|
|
559
|
-
|
|
560
|
-
# Handle form data
|
|
561
|
-
if "application/x-www-form-urlencoded" in content:
|
|
562
|
-
form_schema = content["application/x-www-form-urlencoded"].get("schema", {})
|
|
563
|
-
resolved_schema = self._resolve_schema(form_schema)
|
|
564
|
-
parameters.update(
|
|
565
|
-
self._extract_parameters_from_schema(resolved_schema, "form")
|
|
566
|
-
)
|
|
567
|
-
|
|
568
|
-
return parameters
|
|
569
|
-
|
|
570
|
-
def _extract_body_parameter(self, body_param: Dict[str, Any]) -> Dict[str, str]:
|
|
571
|
-
"""Extract parameters from Swagger 2.0 body parameter with $ref resolution"""
|
|
572
|
-
|
|
573
|
-
if "schema" not in body_param:
|
|
574
|
-
return {}
|
|
575
|
-
|
|
576
|
-
# Resolve the schema reference
|
|
577
|
-
schema = self._resolve_schema(body_param["schema"])
|
|
578
|
-
|
|
579
|
-
return self._extract_parameters_from_schema(schema, "body")
|
|
580
|
-
|
|
581
|
-
def _create_tool_from_operation(
|
|
582
|
-
self,
|
|
583
|
-
method: str,
|
|
584
|
-
path: str,
|
|
585
|
-
operation: Dict[str, Any],
|
|
586
|
-
path_parameters: list,
|
|
587
|
-
base_url: str,
|
|
588
|
-
) -> Optional[OpenAPITool]:
|
|
589
|
-
"""Create a tool definition from an OpenAPI operation"""
|
|
590
|
-
|
|
591
|
-
# Generate operation ID
|
|
592
|
-
operation_id = operation.get("operationId")
|
|
593
|
-
if not operation_id:
|
|
594
|
-
operation_id = f"{method.lower()}{path.replace('/', '_').replace('{', '').replace('}', '')}"
|
|
595
|
-
|
|
596
|
-
# Clean up operation ID to be a valid Python function name
|
|
597
|
-
tool_name = operation_id.replace("-", "_").replace(".", "_").lower()
|
|
598
|
-
|
|
599
|
-
description = (
|
|
600
|
-
operation.get("summary")
|
|
601
|
-
or operation.get("description")
|
|
602
|
-
or f"{method} {path}"
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
# Extract parameters
|
|
606
|
-
parameters = {}
|
|
607
|
-
required_params = []
|
|
608
|
-
|
|
609
|
-
operation_params = operation.get("parameters", [])
|
|
610
|
-
all_parameters = operation_params + path_parameters
|
|
611
|
-
|
|
612
|
-
# Path and query parameters for swagger 2.0
|
|
613
|
-
for param in all_parameters:
|
|
614
|
-
if param.get("in") == "path" or param.get("in") == "query":
|
|
615
|
-
param_name = param["name"]
|
|
616
|
-
|
|
617
|
-
param_type = param.get("type", "string")
|
|
618
|
-
param_schema = param.get("schema", {"type": param_type})
|
|
619
|
-
enhanced_schema = {
|
|
620
|
-
**param_schema,
|
|
621
|
-
"description": param.get("description", ""),
|
|
622
|
-
"in": param.get("in"),
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if "enum" in param_schema:
|
|
626
|
-
enhanced_schema["enum"] = param_schema["enum"]
|
|
627
|
-
enhanced_schema["enum_description"] = self._format_enum_description(
|
|
628
|
-
param_schema.get("enum", []), param.get("description", "")
|
|
629
|
-
)
|
|
630
|
-
|
|
631
|
-
parameters[param_name] = enhanced_schema
|
|
632
|
-
|
|
633
|
-
if param.get("required", False):
|
|
634
|
-
required_params.append(param["name"])
|
|
635
|
-
elif param.get("in") == "body":
|
|
636
|
-
body_params = self._extract_body_parameter(param)
|
|
637
|
-
parameters.update(body_params)
|
|
638
|
-
if param.get("required", False):
|
|
639
|
-
required_params.extend(body_params.keys())
|
|
640
|
-
|
|
641
|
-
# handle request body for openapi 3.0+
|
|
642
|
-
if method in ["POST", "PUT", "PATCH"] and "requestBody" in operation:
|
|
643
|
-
body_params = self._extract_request_body_parameters(
|
|
644
|
-
operation["requestBody"]
|
|
645
|
-
)
|
|
646
|
-
parameters.update(body_params)
|
|
647
|
-
|
|
648
|
-
if operation["requestBody"].get("required", True):
|
|
649
|
-
required_params.extend(body_params.keys())
|
|
650
|
-
|
|
651
|
-
simplified_query = operation.get("x-simplified")
|
|
652
|
-
|
|
653
|
-
# Create parameter schema for MCP
|
|
654
|
-
parameter_schema = {
|
|
655
|
-
"type": "object",
|
|
656
|
-
"properties": parameters,
|
|
657
|
-
"required": required_params,
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return OpenAPITool(
|
|
661
|
-
name=tool_name,
|
|
662
|
-
description=description,
|
|
663
|
-
method=method,
|
|
664
|
-
path=path,
|
|
665
|
-
parameters=parameter_schema,
|
|
666
|
-
base_url=base_url,
|
|
667
|
-
query_filter=simplified_query,
|
|
668
|
-
is_destructive=self._is_tool_destructive(tool_name),
|
|
669
|
-
is_read_only=self._is_tool_read_only(tool_name),
|
|
670
|
-
)
|
|
671
|
-
|
|
672
|
-
def _format_enum_description(
|
|
673
|
-
self, enum_values: List[str], original_description: str
|
|
674
|
-
) -> str:
|
|
675
|
-
"""Format enum values for better tool descriptions"""
|
|
676
|
-
|
|
677
|
-
if not enum_values:
|
|
678
|
-
return original_description
|
|
679
|
-
|
|
680
|
-
enum_list = "\n".join([f" - {value}" for value in enum_values])
|
|
681
|
-
|
|
682
|
-
if original_description:
|
|
683
|
-
return f"{original_description}\n\nAllowed values:\n{enum_list}"
|
|
684
|
-
|
|
685
|
-
return f"Allowed values:\n{enum_list}"
|
|
686
|
-
|
|
687
|
-
def _resolve_schema_ref(self, ref_string: str) -> Dict[str, Any]:
|
|
688
|
-
"""
|
|
689
|
-
Resolve a $ref reference to its actual schema definition
|
|
690
|
-
|
|
691
|
-
Args:
|
|
692
|
-
ref_string: The $ref string like "#/definitions/PackageCopyRequest"
|
|
693
|
-
spec: The full OpenAPI specification
|
|
694
|
-
|
|
695
|
-
Returns:
|
|
696
|
-
The resolved schema definition
|
|
697
|
-
"""
|
|
698
|
-
if not ref_string.startswith("#/"):
|
|
699
|
-
raise ValueError(f"Only local references supported: {ref_string}")
|
|
700
|
-
|
|
701
|
-
if not self.spec:
|
|
702
|
-
raise ValueError("OpenAPI spec not loaded")
|
|
703
|
-
|
|
704
|
-
# Remove the '#/' prefix and split the path
|
|
705
|
-
path_parts = ref_string[2:].split("/")
|
|
706
|
-
|
|
707
|
-
# Navigate through the spec to find the definition
|
|
708
|
-
current = self.spec
|
|
709
|
-
for part in path_parts:
|
|
710
|
-
if part in current:
|
|
711
|
-
current = current[part]
|
|
712
|
-
else:
|
|
713
|
-
raise ValueError(f"Reference not found: {ref_string}")
|
|
714
|
-
|
|
715
|
-
return current
|
|
716
|
-
|
|
717
|
-
def _resolve_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
718
|
-
"""
|
|
719
|
-
Recursively resolve a schema, handling $ref references
|
|
720
|
-
"""
|
|
721
|
-
if "$ref" in schema:
|
|
722
|
-
# Resolve the reference
|
|
723
|
-
resolved = self._resolve_schema_ref(schema["$ref"])
|
|
724
|
-
# Recursively resolve the resolved schema in case it has more refs
|
|
725
|
-
return self._resolve_schema(resolved)
|
|
726
|
-
|
|
727
|
-
# Handle nested schemas
|
|
728
|
-
resolved_schema = schema.copy()
|
|
729
|
-
|
|
730
|
-
# Resolve properties in object schemas
|
|
731
|
-
if "properties" in schema:
|
|
732
|
-
resolved_schema["properties"] = {}
|
|
733
|
-
for prop_name, prop_schema in schema["properties"].items():
|
|
734
|
-
resolved_schema["properties"][prop_name] = self._resolve_schema(
|
|
735
|
-
prop_schema
|
|
736
|
-
)
|
|
737
|
-
|
|
738
|
-
# Resolve items in array schemas
|
|
739
|
-
if "items" in schema:
|
|
740
|
-
resolved_schema["items"] = self._resolve_schema(schema["items"])
|
|
741
|
-
|
|
742
|
-
# Resolve allOf, oneOf, anyOf
|
|
743
|
-
for key in ["allOf", "oneOf", "anyOf"]:
|
|
744
|
-
if key in schema:
|
|
745
|
-
resolved_schema[key] = [
|
|
746
|
-
self._resolve_schema(sub_schema) for sub_schema in schema[key]
|
|
747
|
-
]
|
|
748
|
-
|
|
749
|
-
return resolved_schema
|
|
750
|
-
|
|
751
|
-
def run(self):
|
|
752
|
-
"""Initialize and run the server"""
|
|
753
|
-
asyncio.run(self.load_openapi_spec())
|
|
754
|
-
try:
|
|
755
|
-
self.mcp.run(transport="stdio")
|
|
756
|
-
except asyncio.CancelledError:
|
|
757
|
-
print("Server shutdown requested")
|
|
758
|
-
|
|
759
|
-
def list_tools(self) -> Dict[str, OpenAPITool]:
|
|
760
|
-
"""Initialize and return list of tools. Useful for debugging"""
|
|
761
|
-
asyncio.run(self.load_openapi_spec())
|
|
762
|
-
return self.tools
|
|
763
|
-
|
|
764
|
-
def list_groups(self) -> Dict[str, List[str]]:
|
|
765
|
-
"""Initialize and return list of tool groups with their tools. Useful for debugging"""
|
|
766
|
-
asyncio.run(self.load_openapi_spec())
|
|
767
|
-
|
|
768
|
-
# Build a mapping of group -> list of tools
|
|
769
|
-
groups: Dict[str, List[str]] = {}
|
|
770
|
-
|
|
771
|
-
for tool_name in self.tools:
|
|
772
|
-
tool_groups = self._get_tool_groups(tool_name)
|
|
773
|
-
for group in tool_groups:
|
|
774
|
-
if group not in groups:
|
|
775
|
-
groups[group] = []
|
|
776
|
-
groups[group].append(tool_name)
|
|
777
|
-
|
|
778
|
-
# Sort groups by name and tools within each group
|
|
779
|
-
return {group: sorted(tools) for group, tools in sorted(groups.items())}
|