From 535901d98224946342fc7c731850990a24bcb740 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Fri, 17 Oct 2025 13:51:09 -0300 Subject: [PATCH 01/12] feat(eng-9528): MCP Server --- cloudsmith_cli/cli/commands/__init__.py | 1 + cloudsmith_cli/cli/commands/mcp.py | 297 ++++++++++++ cloudsmith_cli/cli/decorators.py | 17 + cloudsmith_cli/core/mcp/__init__.py | 0 cloudsmith_cli/core/mcp/data.py | 23 + cloudsmith_cli/core/mcp/server.py | 620 ++++++++++++++++++++++++ requirements.in | 1 + requirements.txt | 97 ++-- 8 files changed, 1014 insertions(+), 42 deletions(-) create mode 100644 cloudsmith_cli/cli/commands/mcp.py create mode 100644 cloudsmith_cli/core/mcp/__init__.py create mode 100644 cloudsmith_cli/core/mcp/data.py create mode 100644 cloudsmith_cli/core/mcp/server.py diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index b54a11ab..00e4be8b 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -10,6 +10,7 @@ help_, list_, login, + mcp, metrics, move, policy, diff --git a/cloudsmith_cli/cli/commands/mcp.py b/cloudsmith_cli/cli/commands/mcp.py new file mode 100644 index 00000000..282d333a --- /dev/null +++ b/cloudsmith_cli/cli/commands/mcp.py @@ -0,0 +1,297 @@ +"""Main command/entrypoint.""" + +import json +import os +import shutil +import sys +from pathlib import Path + +import click + +from ...core.mcp import server +from .. import command, decorators, utils +from .main import main + + +@main.group(cls=command.AliasGroup, name="mcp") +@decorators.common_cli_config_options +@decorators.common_api_auth_options +@decorators.initialise_api +@click.pass_context +def mcp_(ctx, opts): # pylint: disable=unused-argument + """ + Start the Cloudsmith MCP Server + + See the help for subcommands for more information on each. + """ + + +@mcp_.command(name="start") +@decorators.initialise_api +@decorators.initialise_mcp +@click.pass_context +def start(ctx, opts, mcp_server: server.DynamicMCPServer): + """ + Start the MCP Server + """ + mcp_server.run() + + +@mcp_.command(name="list_tools") +@decorators.initialise_api +@decorators.initialise_mcp +@click.pass_context +def list_tools(ctx, opts, mcp_server: server.DynamicMCPServer): + """ + List available tools that will be exposed to the AI Client + """ + click.echo("Getting list of tools ... ", nl=False, err=False) + with utils.maybe_spinner(opts): + tools = mcp_server.list_tools() + + print_tools(tools) + + +def print_tools(tool_list): + """Print repositories as a table or output in another format.""" + + headers = [ + "Name", + "Description", + ] + + rows = [] + for tool_name, tools_spec in tool_list.items(): + rows.append( + [ + click.style(tool_name, fg="cyan"), + click.style(tools_spec.description, fg="yellow"), + ] + ) + + if tool_list: + click.echo() + utils.pretty_print_table(headers, rows) + + click.echo() + + num_results = len(tool_list) + list_suffix = "tool%s visible" % ("s" if num_results != 1 else "") + utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix) + + +@mcp_.command(name="configure") +@click.option( + "--client", + type=click.Choice(["claude", "cursor", "vscode"], case_sensitive=False), + help="MCP client to configure (claude, cursor, vscode). If not specified, will attempt to detect and configure all.", +) +@click.option( + "--global/--local", + "is_global", + default=True, + help="Configure globally (default) or in current project directory (local)", +) +@decorators.initialise_api +@click.pass_context +def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument + """ + Configure the Cloudsmith MCP server for supported clients. + + This command automatically adds the Cloudsmith MCP server configuration + to the specified client's configuration file. Supported clients are: + - Claude Desktop + - Cursor IDE + - VS Code (GitHub Copilot) + + Examples:\n + cloudsmith mcp configure --client claude\n + cloudsmith mcp configure --client cursor --local\n + cloudsmith mcp configure # Auto-detect and configure all + """ + # Determine the best command to run the MCP server + server_config = _get_server_config() + + clients_to_configure = [] + if client: + clients_to_configure = [client.lower()] + else: + # Auto-detect available clients + clients_to_configure = detect_available_clients() + + if not clients_to_configure: + click.echo(click.style("No supported MCP clients detected.", fg="yellow")) + click.echo("\nSupported clients:") + click.echo(" - Claude Desktop") + click.echo(" - Cursor IDE") + click.echo(" - VS Code") + return + + success_count = 0 + for client_name in clients_to_configure: + try: + if configure_client(client_name, server_config, is_global): + click.echo( + click.style(f"✓ Configured {client_name.title()}", fg="green") + ) + success_count += 1 + else: + click.echo( + click.style( + f"✗ Failed to configure {client_name.title()}", fg="red" + ) + ) + except Exception as e: + click.echo( + click.style( + f"✗ Error configuring {client_name.title()}: {str(e)}", fg="red" + ) + ) + + if success_count > 0: + click.echo( + click.style( + f"\n✓ Successfully configured {success_count} client(s)", fg="green" + ) + ) + click.echo( + "\nNote: You may need to restart the client application for changes to take effect." + ) + else: + click.echo(click.style("\n✗ No clients were configured successfully", fg="red")) + + +def _get_server_config(): + """Determine the best command configuration to run the MCP server.""" + # Check if running in a virtual environment + in_venv = hasattr(sys, "real_prefix") or ( + hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix + ) + + # In a venv, always use python -m to ensure we use the venv's packages + if in_venv: + return { + "command": sys.executable, + "args": ["-m", "cloudsmith_cli", "mcp", "start"], + } + + # Otherwise, try to find cloudsmith in PATH, fall back to python -m + cloudsmith_cmd = shutil.which("cloudsmith") + if cloudsmith_cmd: + return {"command": cloudsmith_cmd, "args": ["mcp", "start"]} + + return {"command": sys.executable, "args": ["-m", "cloudsmith_cli", "mcp", "start"]} + + +def detect_available_clients(): + """Detect which MCP clients are available on the system.""" + available = [] + + # Check for Claude Desktop + claude_config = get_config_path("claude", is_global=True) + if claude_config and claude_config.parent.exists(): + available.append("claude") + + # Check for Cursor + cursor_config = get_config_path("cursor", is_global=True) + if cursor_config and cursor_config.parent.exists(): + available.append("cursor") + + # Check for VS Code + vscode_config = get_config_path("vscode", is_global=True) + if vscode_config and vscode_config.parent.exists(): + available.append("vscode") + + return available + + +def get_config_path(client_name, is_global=True): + """Get the configuration file path for a given client.""" + home = Path.home() + appdata = os.getenv("APPDATA", "") + + # Configuration paths by client, platform, and scope + config_paths = { + "claude": { + "darwin": home + / "Library" + / "Application Support" + / "Claude" + / "claude_desktop_config.json", + "win32": Path(appdata) / "Claude" / "claude_desktop_config.json" + if appdata + else None, + "linux": home / ".config" / "Claude" / "claude_desktop_config.json", + }, + "cursor": { + "global": home / ".cursor" / "mcp.json", + "local": Path.cwd() / ".cursor" / "mcp.json", + }, + "vscode": { + "darwin": home + / "Library" + / "Application Support" + / "Code" + / "User" + / "settings.json", + "win32": Path(appdata) / "Code" / "User" / "settings.json" + if appdata + else None, + "linux": home / ".config" / "Code" / "User" / "settings.json", + "local": Path.cwd() / ".vscode" / "settings.json", + }, + } + + client_config = config_paths.get(client_name, {}) + + # For Cursor, use global/local scope instead of platform + if client_name == "cursor": + scope = "global" if is_global else "local" + return client_config.get(scope) + + # For VS Code local config + if client_name == "vscode" and not is_global: + return client_config.get("local") + + # For platform-specific configs (Claude and VS Code global) + platform = sys.platform if sys.platform in ("darwin", "win32") else "linux" + return client_config.get(platform) + + +def configure_client(client_name, server_config, is_global=True): + """Configure a specific MCP client with the Cloudsmith server.""" + config_path = get_config_path(client_name, is_global) + + if not config_path: + return False + + # Ensure parent directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing config or create new one + if config_path.exists(): + with open(config_path) as f: + try: + config = json.load(f) + except json.JSONDecodeError: + config = {} + else: + config = {} + + # Add Cloudsmith MCP server based on client format + if client_name == "claude" or client_name == "cursor": + if "mcpServers" not in config: + config["mcpServers"] = {} + config["mcpServers"]["cloudsmith"] = server_config + + elif client_name == "vscode": + # VS Code uses a different format in settings.json + if "chat.mcp.servers" not in config: + config["chat.mcp.servers"] = {} + config["chat.mcp.servers"]["cloudsmith"] = server_config + + # Write updated config + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + + return True diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index a8b41173..46eb846a 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -5,6 +5,7 @@ import click from ..core.api.init import initialise_api as _initialise_api +from ..core.mcp import server from . import config, utils, validators @@ -309,3 +310,19 @@ def call_print_rate_limit_info_with_opts(rate_info): return ctx.invoke(f, *args, **kwargs) return wrapper + + +def initialise_mcp(f): + @click.pass_context + @functools.wraps(f) + def wrapper(ctx, *args, **kwargs): + opts = kwargs.get("opts") + mcp_server = server.DynamicMCPServer( + api_base_url=opts.api_config.host, + api_token=opts.api_key, + debug_mode=opts.debug, + ) + kwargs["mcp_server"] = mcp_server + return ctx.invoke(f, *args, **kwargs) + + return wrapper diff --git a/cloudsmith_cli/core/mcp/__init__.py b/cloudsmith_cli/core/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudsmith_cli/core/mcp/data.py b/cloudsmith_cli/core/mcp/data.py new file mode 100644 index 00000000..8cc5d772 --- /dev/null +++ b/cloudsmith_cli/core/mcp/data.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Any, Dict + +import httpx + + +@dataclass +class OpenAPITool: + """Represents a tool generated from OpenAPI spec""" + + name: str + description: str + method: str + path: str + parameters: Dict[str, Any] + base_url: str + + +@dataclass +class AppContext: + """Application context for storing OpenAPI tools and HTTP client""" + + http_client: httpx.AsyncClient diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py new file mode 100644 index 00000000..f0d6eae3 --- /dev/null +++ b/cloudsmith_cli/core/mcp/server.py @@ -0,0 +1,620 @@ +import asyncio +import copy +import inspect +import json +from typing import Any, Dict, List, Optional + +import httpx +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.shared._httpx_utils import create_mcp_http_client + +from .data import OpenAPITool + +ALLOWED_METHODS = ["get", "post", "put", "delete", "patch"] + +API_VERSIONS_TO_DISCOVER = { + "v1": "swagger/?format=openapi", + "v2": "openapi/?format=json", +} + + +class CustomFastMCP(FastMCP): + """Custom FastMCP that overrides tool listing to clean up schemas to not overwhelm the LLM context""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def list_tools(self) -> list[types.Tool]: + """Override to clean up tool schemas""" + # Get the default tools from parent (returns list[MCPTool]) + default_tools = await super().list_tools() + + # Clean up each tool's schema + cleaned_tools = [] + for tool in default_tools: + # Create a new MCPTool with cleaned schema + cleaned_tool = types.Tool( + name=tool.name, + description=tool.description, + inputSchema=self._clean_schema(tool.inputSchema), + ) + cleaned_tools.append(cleaned_tool) + + return cleaned_tools + + def _clean_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Clean up schema by removing anyOf patterns and other complexities""" + if not isinstance(schema, dict): + return schema + + cleaned = copy.deepcopy(schema) + + # Clean properties recursively + if "properties" in cleaned: + cleaned_properties = {} + for prop_name, prop_schema in cleaned["properties"].items(): + cleaned_properties[prop_name] = self._clean_property_schema(prop_schema) + cleaned["properties"] = cleaned_properties + + return cleaned + + def _clean_property_schema(self, prop_schema: Dict[str, Any]) -> Dict[str, Any]: + """Clean individual property schema""" + if not isinstance(prop_schema, dict): + return prop_schema + + cleaned = copy.deepcopy(prop_schema) + + # Handle anyOf patterns - extract the non-null type + if "anyOf" in cleaned: + non_null_schemas = [ + item + for item in cleaned["anyOf"] + if not (isinstance(item, dict) and item.get("type") == "null") + ] + + if len(non_null_schemas) == 1: + # Replace anyOf with the single non-null type + non_null_schema = non_null_schemas[0] + + # Merge the non-null schema properties + for key, value in non_null_schema.items(): + if key not in cleaned or key == "type": + cleaned[key] = value + + # Remove the anyOf + del cleaned["anyOf"] + + # Handle oneOf with single option + if "oneOf" in cleaned and len(cleaned["oneOf"]) == 1: + single_schema = cleaned["oneOf"][0] + for key, value in single_schema.items(): + if key not in cleaned or key == "type": + cleaned[key] = value + del cleaned["oneOf"] + + # Remove nullable indicators + if "nullable" in cleaned: + del cleaned["nullable"] + + # Clean up title if it's auto-generated and not useful + if "title" in cleaned and cleaned["title"].endswith("Arguments"): + del cleaned["title"] + + # Recursively clean nested schemas + if "properties" in cleaned: + nested_properties = {} + for nested_name, nested_schema in cleaned["properties"].items(): + nested_properties[nested_name] = self._clean_property_schema( + nested_schema + ) + cleaned["properties"] = nested_properties + + if "items" in cleaned: + cleaned["items"] = self._clean_property_schema(cleaned["items"]) + + return cleaned + + +class DynamicMCPServer: + """MCP Server that dynamically generates tools from Cloudsmith's OpenAPI specs""" + + def __init__( + self, + name: str = "Cloudsmith MCP Server", + port: int = 8089, + api_base_url: str = "", + api_token: str = "", + debug_mode=False, + ): + mcp_kwargs = {"log_level": "ERROR"} + if debug_mode: + mcp_kwargs["log_level"] = "DEBUG" + self.mcp = CustomFastMCP(name, **mcp_kwargs) + self.api_base_url = api_base_url + self.api_token = api_token + self.tools: Dict[str, OpenAPITool] = {} + + async def load_openapi_spec(self): + """Load OpenAPI spec and generate tools dynamically""" + + if not self.api_base_url: + raise Exception("The Cloudsmith API has to be set") + + async with create_mcp_http_client() as http_client: + http_client = httpx.AsyncClient(timeout=30.0) + + for version, endpoint in API_VERSIONS_TO_DISCOVER.items(): + spec_url = f"{self.api_base_url}/{version}/{endpoint}" + # print(f"Fetching OpenAPI spec from {spec_url}") + response = await http_client.get(spec_url) + response.raise_for_status() + self.spec = response.json() + await self._generate_tools_from_spec() + + def _is_tool_allowed(self, tool_name: str) -> bool: + """Check if a tool is allowed based on some user input""" + + # TODO: tool filtering + + return True + + async def _generate_tools_from_spec(self): + """Generate MCP tools from OpenAPI specification""" + + if not self.spec: + raise ValueError("OpenAPI spec not loaded") + + # print(f"Generating tools for base URL: {self.api_base_url}") + + # Parse paths and generate tools + for path, path_item in self.spec.get("paths", {}).items(): + for method, operation in path_item.items(): + path_parameters = path_item.get("parameters", []) + + if method.lower() in ALLOWED_METHODS: + tool = self._create_tool_from_operation( + method.upper(), + path, + operation, + path_parameters, + self.api_base_url, + ) + if tool and self._is_tool_allowed(tool.name): + self.tools[tool.name] = tool + self._register_dynamic_tool(tool) + + # print(f"Generated {len(self.tools)} tools from OpenAPI spec") + + def _register_dynamic_tool(self, api_tool: OpenAPITool): + """Register a single tool dynamically with the MCP server""" + + # Create the tool function dynamically + async def dynamic_tool_func(**kwargs) -> str: + return await self._execute_api_call(api_tool, kwargs) + + # Set function metadata for MCP + dynamic_tool_func.__name__ = api_tool.name + + docstring_parts = [api_tool.description] + properties = api_tool.parameters.get("properties", {}) + if properties: + docstring_parts.append("\nParameters:") + for param_name, param_schema in properties.items(): + param_type = param_schema.get("type", "string") + param_desc = param_schema.get("description", "") + + param_line = f"{param_name} ({param_type})" + + # Add enum information + if "enum" in param_schema: + enum_values = map(str, param_schema["enum"]) + param_line += f" - One of: {', '.join(enum_values)}" + + # Add default if available + if "default" in param_schema: + param_line += f" (default: {param_schema['default']})" + + if param_desc: + param_line += f": {param_desc}" + + docstring_parts.append(param_line) + + dynamic_tool_func.__doc__ = "\n".join(docstring_parts) + + annotations = {"return": str} # Set return type annotation + + # Create parameter annotations for better type checking + sig_params = [] + for param_name, param_schema in properties.items(): + # For enum parameters, we could create a custom type, but for simplicity use str + if "enum" in param_schema: + param_type = str # MCP will handle validation + else: + param_type = self._schema_type_to_python_type( + param_schema.get("type", "string") + ) + + # TODO: Refactor this to not need the conditional, just use kwargs + if param_name not in api_tool.parameters.get("required", []): + # Create parameter with default value + default = param_schema.get("default", None) + annotation_type = ( + param_type if default is not None else Optional[param_type] + ) + sig_params.append( + inspect.Parameter( + param_name, + inspect.Parameter.KEYWORD_ONLY, + annotation=annotation_type, + default=param_schema.get("default", None), + ) + ) + else: + sig_params.append( + inspect.Parameter( + param_name, + inspect.Parameter.KEYWORD_ONLY, + annotation=param_type, + ) + ) + annotations[param_name] = param_type + + # Create new signature + dynamic_tool_func.__signature__ = inspect.Signature(sig_params) + + # TODO: mark dangerous operations with `destructiveHint` + # https://modelcontextprotocol.io/docs/concepts/tools#purpose-of-tool-annotations + dynamic_tool_func.__annotations__ = annotations + + # Register with MCP server - this uses the decorator approach + self.mcp.tool()(dynamic_tool_func) + + # print(f"Registered tool: {api_tool.name} ({api_tool.method} {api_tool.path})") + + def _schema_type_to_python_type(self, schema_type: str): + """Convert OpenAPI schema type to Python type""" + type_mapping = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, + } + return type_mapping.get(schema_type, str) + + async def _execute_api_call( + self, tool: OpenAPITool, arguments: Dict[str, Any] + ) -> str: + """Execute an API call based on tool definition""" + http_client = create_mcp_http_client( + headers={ + "X-Api-Key": self.api_token, + "Accept": "application/json", + } + ) + + # Build URL with path parameters + url = tool.base_url + tool.path + path_params = {} + query_params = {} + body_params = {} + + # Separate parameters by type based on OpenAPI spec + properties = tool.parameters.get("properties", {}) + validated_arguments = {} + + for key, value in arguments.items(): + if key in properties: + param_schema = properties[key] + + # Skip None values for optional parameters + if value is None: + if "default" in param_schema: + validated_arguments[key] = param_schema["default"] + continue + + # Validate enum values + if "enum" in param_schema: + if value not in param_schema["enum"]: + allowed_values = ", ".join(param_schema["enum"]) + return f"Invalid value '{value}' for parameter '{key}'. Allowed values: {allowed_values}" + + validated_arguments[key] = value + else: + validated_arguments[key] = value + + for key, value in validated_arguments.items(): + if key in properties: + if "{" + key + "}" in url: + # Path parameter + path_params[key] = value + url = url.replace("{" + key + "}", str(value)) + elif tool.method in ["GET", "DELETE"]: + # Query parameter for GET/DELETE + query_params[key] = value + else: + # Body parameter for POST/PUT/PATCH + body_params[key] = value + + try: + # print(f"Calling {tool.method} {url}") + + # Make the API call + if tool.method == "GET": + response = await http_client.get(url, params=query_params) + elif tool.method == "POST": + response = await http_client.post( + url, json=body_params, params=query_params + ) + elif tool.method == "PUT": + response = await http_client.put( + url, json=body_params, params=query_params + ) + elif tool.method == "DELETE": + response = await http_client.delete(url, params=query_params) + elif tool.method == "PATCH": + response = await http_client.patch( + url, json=body_params, params=query_params + ) + else: + # Emm did you invent a new HTTP method that I'm not aware of? + return f"Unsupported HTTP method: {tool.method}" + + response.raise_for_status() + + # Return formatted response + try: + result = response.json() + return json.dumps(result, indent=2) + except: + return response.text + + except httpx.HTTPError as e: + return f"HTTP error: {str(e)}" + except Exception as e: + return f"Error executing API call: {str(e)}" + finally: + await http_client.aclose() + + def _extract_parameters_from_schema( + self, schema: Dict[str, Any], param_in: str = "body" + ) -> Dict[str, Any]: + """Extract individual parameters from a resolved schema object""" + + parameters = {} + + if schema.get("type") == "object" and "properties" in schema: + for prop_name, prop_schema in schema["properties"].items(): + enhanced_schema = { + **prop_schema, + "in": param_in, + "description": prop_schema.get("description", ""), + } + + # Handle enum descriptions + if "enum" in prop_schema: + enhanced_schema["enum_description"] = self._format_enum_description( + prop_schema["enum"], prop_schema.get("description", "") + ) + + parameters[prop_name] = enhanced_schema + + return parameters + + def _extract_request_body_parameters( + self, request_body: Dict[str, Any] + ) -> Dict[str, Any]: + """Extract parameters from OpenAPI 3.0 request body with $ref resolution""" + + parameters = {} + content = request_body.get("content", {}) + + # Handle JSON request body + if "application/json" in content: + json_schema = content["application/json"].get("schema", {}) + resolved_schema = self._resolve_schema(json_schema) + parameters.update( + self._extract_parameters_from_schema(resolved_schema, "body") + ) + + # Handle form data + if "application/x-www-form-urlencoded" in content: + form_schema = content["application/x-www-form-urlencoded"].get("schema", {}) + resolved_schema = self._resolve_schema(form_schema) + parameters.update( + self._extract_parameters_from_schema(resolved_schema, "form") + ) + + return parameters + + def _extract_body_parameter(self, body_param: Dict[str, Any]) -> Dict[str, str]: + """Extract parameters from Swagger 2.0 body parameter with $ref resolution""" + + if "schema" not in body_param: + return {} + + # Resolve the schema reference + schema = self._resolve_schema(body_param["schema"]) + + return self._extract_parameters_from_schema(schema, "body") + + def _create_tool_from_operation( + self, + method: str, + path: str, + operation: Dict[str, Any], + path_parameters: list, + base_url: str, + ) -> Optional[OpenAPITool]: + """Create a tool definition from an OpenAPI operation""" + + # Generate operation ID + operation_id = operation.get("operationId") + if not operation_id: + operation_id = f"{method.lower()}{path.replace('/', '_').replace('{', '').replace('}', '')}" + + # Clean up operation ID to be a valid Python function name + tool_name = operation_id.replace("-", "_").replace(".", "_").lower() + + description = ( + operation.get("summary") + or operation.get("description") + or f"{method} {path}" + ) + + # Extract parameters + parameters = {} + required_params = [] + + operation_params = operation.get("parameters", []) + all_parameters = operation_params + path_parameters + + # Path and query parameters for swagger 2.0 + for param in all_parameters: + if param.get("in") == "path" or param.get("in") == "query": + param_name = param["name"] + + param_type = param.get("type", "string") + param_schema = param.get("schema", {"type": param_type}) + enhanced_schema = { + **param_schema, + "description": param.get("description", ""), + "in": param.get("in"), + } + + if "enum" in param_schema: + enhanced_schema["enum"] = param_schema["enum"] + enhanced_schema["enum_description"] = self._format_enum_description( + param_schema.get("enum", []), param.get("description", "") + ) + + parameters[param_name] = enhanced_schema + + if param.get("required", False): + required_params.append(param["name"]) + elif param.get("in") == "body": + body_params = self._extract_body_parameter(param) + parameters.update(body_params) + if param.get("required", False): + required_params.extend(body_params.keys()) + + # handle request body for openapi 3.0+ + if method in ["POST", "PUT", "PATCH"] and "requestBody" in operation: + body_params = self._extract_request_body_parameters( + operation["requestBody"] + ) + parameters.update(body_params) + + if operation["requestBody"].get("required", True): + required_params.extend(body_params.keys()) + + # Create parameter schema for MCP + parameter_schema = { + "type": "object", + "properties": parameters, + "required": required_params, + } + + return OpenAPITool( + name=tool_name, + description=description, + method=method, + path=path, + parameters=parameter_schema, + base_url=base_url, + ) + + def _format_enum_description( + self, enum_values: List[str], original_description: str + ) -> str: + """Format enum values for better tool descriptions""" + + if not enum_values: + return original_description + + enum_list = "\n".join([f" - {value}" for value in enum_values]) + + if original_description: + return f"{original_description}\n\nAllowed values:\n{enum_list}" + else: + return f"Allowed values:\n{enum_list}" + + def _resolve_schema_ref(self, ref_string: str) -> Dict[str, Any]: + """ + Resolve a $ref reference to its actual schema definition + + Args: + ref_string: The $ref string like "#/definitions/PackageCopyRequest" + spec: The full OpenAPI specification + + Returns: + The resolved schema definition + """ + if not ref_string.startswith("#/"): + raise ValueError(f"Only local references supported: {ref_string}") + + if not self.spec: + raise ValueError("OpenAPI spec not loaded") + + # Remove the '#/' prefix and split the path + path_parts = ref_string[2:].split("/") + + # Navigate through the spec to find the definition + current = self.spec + for part in path_parts: + if part in current: + current = current[part] + else: + raise ValueError(f"Reference not found: {ref_string}") + + return current + + def _resolve_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Recursively resolve a schema, handling $ref references + """ + if "$ref" in schema: + # Resolve the reference + resolved = self._resolve_schema_ref(schema["$ref"]) + # Recursively resolve the resolved schema in case it has more refs + return self._resolve_schema(resolved) + + # Handle nested schemas + resolved_schema = schema.copy() + + # Resolve properties in object schemas + if "properties" in schema: + resolved_schema["properties"] = {} + for prop_name, prop_schema in schema["properties"].items(): + resolved_schema["properties"][prop_name] = self._resolve_schema( + prop_schema + ) + + # Resolve items in array schemas + if "items" in schema: + resolved_schema["items"] = self._resolve_schema(schema["items"]) + + # Resolve allOf, oneOf, anyOf + for key in ["allOf", "oneOf", "anyOf"]: + if key in schema: + resolved_schema[key] = [ + self._resolve_schema(sub_schema) for sub_schema in schema[key] + ] + + return resolved_schema + + def run(self): + """Initialize and run the server""" + asyncio.run(self.load_openapi_spec()) + try: + self.mcp.run(transport="stdio") + except asyncio.CancelledError: + print("Server shutdown requested") + + def list_tools(self): + """Initialize and return list of tools. Useful for debugging""" + asyncio.run(self.load_openapi_spec()) + return self.tools diff --git a/requirements.in b/requirements.in index c9fc05c4..d888dcd6 100644 --- a/requirements.in +++ b/requirements.in @@ -6,3 +6,4 @@ pip-tools pre-commit pylint pytest-cov +mcp==1.9.1 diff --git a/requirements.txt b/requirements.txt index bd463990..467756cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,17 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --output-file=requirements.txt --pre requirements.in setup.py +# pip-compile # +annotated-types==0.7.0 + # via pydantic +anyio==4.11.0 + # via + # httpx + # mcp + # sse-starlette + # starlette astroid==2.15.6 # via pylint build==0.10.0 @@ -14,28 +22,14 @@ bumpversion==0.6.0 # via -r requirements.in certifi==2023.7.22 # via - # cloudsmith-api - # requests + # httpcore + # httpx cfgv==3.3.1 # via pre-commit -charset-normalizer==3.2.0 - # via requests click==8.1.6 # via - # click-configfile - # click-didyoumean - # cloudsmith-cli (setup.py) # pip-tools -click-configfile==0.2.3 - # via cloudsmith-cli (setup.py) -click-didyoumean==0.3.0 - # via cloudsmith-cli (setup.py) -click-spinner==0.1.10 - # via cloudsmith-cli (setup.py) -cloudsmith-api==2.0.7 - # via cloudsmith-cli (setup.py) -configparser==6.0.0 - # via click-configfile + # uvicorn coverage[toml]==7.2.7 # via pytest-cov dill==0.3.7 @@ -43,15 +37,29 @@ dill==0.3.7 distlib==0.3.7 # via virtualenv exceptiongroup==1.1.2 - # via pytest + # via + # anyio + # pytest filelock==3.12.2 # via virtualenv +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx httpretty==1.1.4 # via -r requirements.in +httpx==0.28.1 + # via mcp +httpx-sse==0.4.3 + # via mcp identify==2.5.26 # via pre-commit idna==3.4 - # via requests + # via + # anyio + # httpx iniconfig==2.0.0 # via pytest isort==5.12.0 @@ -60,6 +68,8 @@ lazy-object-proxy==1.9.0 # via astroid mccabe==0.7.0 # via pylint +mcp==1.9.1 + # via -r requirements.in nodeenv==1.8.0 # via pre-commit packaging==23.1 @@ -76,6 +86,14 @@ pluggy==1.2.0 # via pytest pre-commit==3.3.3 # via -r requirements.in +pydantic==2.9.2 + # via + # mcp + # pydantic-settings +pydantic-core==2.23.4 + # via pydantic +pydantic-settings==2.8.1 + # via mcp pylint==3.0.0a6 # via -r requirements.in pyproject-hooks==1.0.0 @@ -84,23 +102,18 @@ pytest==7.4.0 # via pytest-cov pytest-cov==4.1.0 # via -r requirements.in -python-dateutil==2.8.2 - # via cloudsmith-api +python-dotenv==1.1.1 + # via pydantic-settings +python-multipart==0.0.20 + # via mcp pyyaml==6.0.1 # via pre-commit -requests==2.31.0 - # via - # cloudsmith-cli (setup.py) - # requests-toolbelt -requests-toolbelt==1.0.0 - # via cloudsmith-cli (setup.py) -semver==3.0.1 - # via cloudsmith-cli (setup.py) -six==1.16.0 - # via - # click-configfile - # cloudsmith-api - # python-dateutil +sniffio==1.3.1 + # via anyio +sse-starlette==3.0.2 + # via mcp +starlette==0.47.0 + # via mcp tomli==2.0.1 # via # build @@ -113,13 +126,13 @@ tomlkit==0.12.1 # via pylint typing-extensions==4.7.1 # via + # anyio # astroid - # pylint -urllib3==1.26.16 - # via - # cloudsmith-api - # cloudsmith-cli (setup.py) - # requests + # pydantic + # pydantic-core + # uvicorn +uvicorn==0.37.0 + # via mcp virtualenv==20.24.2 # via pre-commit wheel==0.41.1 From f908540316f22f4458ded3beaa0bd4e1e00c8084 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Tue, 2 Dec 2025 10:30:13 +0000 Subject: [PATCH 02/12] fix: group filtering and use TOON --- cloudsmith_cli/cli/commands/mcp.py | 56 +++++++++++- cloudsmith_cli/core/mcp/server.py | 139 +++++++++++++++++++++++++++-- requirements.in | 1 + requirements.txt | 2 + 4 files changed, 189 insertions(+), 9 deletions(-) diff --git a/cloudsmith_cli/cli/commands/mcp.py b/cloudsmith_cli/cli/commands/mcp.py index 282d333a..1ffeb579 100644 --- a/cloudsmith_cli/cli/commands/mcp.py +++ b/cloudsmith_cli/cli/commands/mcp.py @@ -52,8 +52,23 @@ def list_tools(ctx, opts, mcp_server: server.DynamicMCPServer): print_tools(tools) +@mcp_.command(name="list_groups") +@decorators.initialise_api +@decorators.initialise_mcp +@click.pass_context +def list_groups(ctx, opts, mcp_server: server.DynamicMCPServer): + """ + List available tool groups and the tools they contain + """ + click.echo("Getting list of tool groups ... ", nl=False, err=False) + with utils.maybe_spinner(opts): + groups = mcp_server.list_groups() + + print_groups(groups) + + def print_tools(tool_list): - """Print repositories as a table or output in another format.""" + """Print tools as a table or output in another format.""" headers = [ "Name", @@ -80,6 +95,41 @@ def print_tools(tool_list): utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix) +def print_groups(group_list): + """Print tool groups as a table or output in another format.""" + + headers = [ + "Group Name", + "Tool Count", + "Sample Tools", + ] + + rows = [] + for group_name, tools in group_list.items(): + # Show first 3 tools as samples + sample_tools = ", ".join(tools[:3]) + if len(tools) > 3: + sample_tools += f", ... (+{len(tools) - 3} more)" + + rows.append( + [ + click.style(group_name, fg="cyan"), + click.style(str(len(tools)), fg="yellow"), + click.style(sample_tools, fg="white"), + ] + ) + + if group_list: + click.echo() + utils.pretty_print_table(headers, rows) + + click.echo() + + num_results = len(group_list) + list_suffix = "group%s visible" % ("s" if num_results != 1 else "") + utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix) + + @mcp_.command(name="configure") @click.option( "--client", @@ -141,7 +191,7 @@ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument f"✗ Failed to configure {client_name.title()}", fg="red" ) ) - except Exception as e: + except OSError as e: click.echo( click.style( f"✗ Error configuring {client_name.title()}: {str(e)}", fg="red" @@ -279,7 +329,7 @@ def configure_client(client_name, server_config, is_global=True): config = {} # Add Cloudsmith MCP server based on client format - if client_name == "claude" or client_name == "cursor": + if client_name in {"claude", "cursor"}: if "mcpServers" not in config: config["mcpServers"] = {} config["mcpServers"]["cloudsmith"] = server_config diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index f0d6eae3..c03f9da4 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -5,9 +5,10 @@ from typing import Any, Dict, List, Optional import httpx -import mcp.types as types +from mcp import types from mcp.server.fastmcp import FastMCP from mcp.shared._httpx_utils import create_mcp_http_client +from toon_python import encode from .data import OpenAPITool @@ -17,6 +18,57 @@ "v1": "swagger/?format=openapi", "v2": "openapi/?format=json", } +TOOL_DELETE_SUFFIXES = ["delete", "destroy", "remove"] + +# Common action suffixes in OpenAPI operation IDs +# These should not be considered as part of the resource group hierarchy +TOOL_ACTION_SUFFIXES = [ + "create", + "read", + "list", + "update", + "partial_update", + "delete", + "destroy", + "retrieve", + "remove", +] + +DEFAULT_DISABLED_CATEGORIES = [ + "broadcasts", + "rates", + "packages_upload", + "packages_validate", + "user_token", + "user_tokens", + "webhooks", + "status", + "repos_ecdsa", + "repos_geoip", + "repos_gpg", + "repos_rsa", + "repos_x509", + "repos_upstream", + "orgs_openid", + "orgs_saml", + "orgs_invites", + "files", + "badges", + "quota", + "users_profile", + "workspaces_policies", + "storage_regions", + "entitlements", + "metrics_entitlements", + "metrics_packages", + "orgs_teams", + "repo_retention", +] + +DEFAULT_DISABLED_TOOLS = [ + "namespaces_read", + "orgs_read", +] class CustomFastMCP(FastMCP): @@ -123,10 +175,12 @@ class DynamicMCPServer: def __init__( self, name: str = "Cloudsmith MCP Server", - port: int = 8089, api_base_url: str = "", api_token: str = "", + use_toon=True, + allow_destructive_tools=False, debug_mode=False, + disabled_tool_groups: Optional[List[str]] = DEFAULT_DISABLED_CATEGORIES, ): mcp_kwargs = {"log_level": "ERROR"} if debug_mode: @@ -134,6 +188,9 @@ def __init__( self.mcp = CustomFastMCP(name, **mcp_kwargs) self.api_base_url = api_base_url self.api_token = api_token + self.use_toon = use_toon + self.allow_destructive_tools = allow_destructive_tools + self.disabled_tool_groups = set(disabled_tool_groups or []) self.tools: Dict[str, OpenAPITool] = {} async def load_openapi_spec(self): @@ -147,16 +204,67 @@ async def load_openapi_spec(self): for version, endpoint in API_VERSIONS_TO_DISCOVER.items(): spec_url = f"{self.api_base_url}/{version}/{endpoint}" - # print(f"Fetching OpenAPI spec from {spec_url}") response = await http_client.get(spec_url) response.raise_for_status() self.spec = response.json() await self._generate_tools_from_spec() + def _get_tool_groups(self, tool_name: str) -> List[str]: + """ + Extract all hierarchical group names from a tool name, excluding action suffixes. + + Examples: + webhooks_create -> ['webhooks'] + repos_upstream_swift_list -> ['repos', 'repos_upstream', 'repos_upstream_swift'] + vulnerabilities_read -> ['vulnerabilities'] + workspaces_policies_actions_partial_update -> ['workspaces', 'workspaces_policies'] + repos_upstream_huggingface_partial_update -> ['repos', 'repos_upstream', 'repos_upstream_huggingface'] + """ + groups = [] + parts = tool_name.split("_") + + # Determine how many parts belong to the action suffix + # Sort by length descending to match longest suffixes first + sorted_suffixes = sorted( + TOOL_ACTION_SUFFIXES, key=lambda x: len(x.split("_")), reverse=True + ) + + action_parts_count = 0 + for action_suffix in sorted_suffixes: + action_suffix_parts = action_suffix.split("_") + if len(parts) >= len(action_suffix_parts): + # Check if the end of the tool name matches this action suffix + if parts[-len(action_suffix_parts) :] == action_suffix_parts: + action_parts_count = len(action_suffix_parts) + break + + # If no action suffix found, treat the last part as the action + if action_parts_count == 0: + action_parts_count = 1 + + # Build hierarchical groups by progressively adding parts, excluding action suffix + resource_parts = ( + parts[:-action_parts_count] if action_parts_count > 0 else parts + ) + for i in range(1, len(resource_parts) + 1): + group = "_".join(resource_parts[:i]) + groups.append(group) + + return groups + def _is_tool_allowed(self, tool_name: str) -> bool: - """Check if a tool is allowed based on some user input""" + """Check if a tool is allowed based on user configuration""" + + # Check if tool is destructive and destructive tools are disabled + if not self.allow_destructive_tools and any( + suffix in tool_name for suffix in TOOL_DELETE_SUFFIXES + ): + return False - # TODO: tool filtering + # Check if any of the tool's groups are disabled + tool_groups = self._get_tool_groups(tool_name) + if any(group in self.disabled_tool_groups for group in tool_groups): + return False return True @@ -368,8 +476,10 @@ async def _execute_api_call( # Return formatted response try: result = response.json() + if self.use_toon: + return encode(result) return json.dumps(result, indent=2) - except: + except Exception: return response.text except httpx.HTTPError as e: @@ -618,3 +728,20 @@ def list_tools(self): """Initialize and return list of tools. Useful for debugging""" asyncio.run(self.load_openapi_spec()) return self.tools + + def list_groups(self) -> Dict[str, List[str]]: + """Initialize and return list of tool groups with their tools. Useful for debugging""" + asyncio.run(self.load_openapi_spec()) + + # Build a mapping of group -> list of tools + groups: Dict[str, List[str]] = {} + + for tool_name in self.tools.keys(): + tool_groups = self._get_tool_groups(tool_name) + for group in tool_groups: + if group not in groups: + groups[group] = [] + groups[group].append(tool_name) + + # Sort groups by name and tools within each group + return {group: sorted(tools) for group, tools in sorted(groups.items())} diff --git a/requirements.in b/requirements.in index d888dcd6..d251108d 100644 --- a/requirements.in +++ b/requirements.in @@ -6,4 +6,5 @@ pip-tools pre-commit pylint pytest-cov +toon-python mcp==1.9.1 diff --git a/requirements.txt b/requirements.txt index 467756cd..d0cc81e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -124,6 +124,8 @@ tomli==2.0.1 # pytest tomlkit==0.12.1 # via pylint +toon-python==0.1.2 + # via -r requirements.in typing-extensions==4.7.1 # via # anyio From 5aad05dbad760fe984a7a9a3fa287d2f6ff2c0e3 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Fri, 5 Dec 2025 12:57:21 +0000 Subject: [PATCH 03/12] fix: implement simplified api requests --- cloudsmith_cli/core/mcp/data.py | 1 + cloudsmith_cli/core/mcp/server.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cloudsmith_cli/core/mcp/data.py b/cloudsmith_cli/core/mcp/data.py index 8cc5d772..95694c3a 100644 --- a/cloudsmith_cli/core/mcp/data.py +++ b/cloudsmith_cli/core/mcp/data.py @@ -14,6 +14,7 @@ class OpenAPITool: path: str parameters: Dict[str, Any] base_url: str + query_filter: str | None @dataclass diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index c03f9da4..33436c88 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -447,9 +447,14 @@ async def _execute_api_call( # Body parameter for POST/PUT/PATCH body_params[key] = value - try: - # print(f"Calling {tool.method} {url}") + if tool.query_filter: + parsed_simplified_filter = { + k: v + for k, v in map(lambda x: x.split("="), tool.query_filter.split("&")) + } + query_params.update(parsed_simplified_filter) + try: # Make the API call if tool.method == "GET": response = await http_client.get(url, params=query_params) @@ -621,6 +626,8 @@ def _create_tool_from_operation( if operation["requestBody"].get("required", True): required_params.extend(body_params.keys()) + simplified_query = operation.get("x-simplified") + # Create parameter schema for MCP parameter_schema = { "type": "object", @@ -635,6 +642,7 @@ def _create_tool_from_operation( path=path, parameters=parameter_schema, base_url=base_url, + query_filter=simplified_query, ) def _format_enum_description( From 2ecb5e2532083820c445def6790d989e147e6b59 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Mon, 8 Dec 2025 15:26:25 +0000 Subject: [PATCH 04/12] fix: support CLI profiles --- cloudsmith_cli/cli/commands/mcp.py | 54 +++++++++++++------------ cloudsmith_cli/cli/config.py | 31 +++++++++++++++ cloudsmith_cli/cli/decorators.py | 30 +++++++++++--- cloudsmith_cli/core/mcp/server.py | 63 +++++++++++++++++++----------- 4 files changed, 127 insertions(+), 51 deletions(-) diff --git a/cloudsmith_cli/cli/commands/mcp.py b/cloudsmith_cli/cli/commands/mcp.py index 1ffeb579..f4ce4b26 100644 --- a/cloudsmith_cli/cli/commands/mcp.py +++ b/cloudsmith_cli/cli/commands/mcp.py @@ -12,6 +12,8 @@ from .. import command, decorators, utils from .main import main +SUPPORTED_MCP_CLIENTS = ["claude", "cursor", "vscode"] + @main.group(cls=command.AliasGroup, name="mcp") @decorators.common_cli_config_options @@ -43,7 +45,7 @@ def start(ctx, opts, mcp_server: server.DynamicMCPServer): @click.pass_context def list_tools(ctx, opts, mcp_server: server.DynamicMCPServer): """ - List available tools that will be exposed to the AI Client + List available tools that will be exposed to the MCP Client """ click.echo("Getting list of tools ... ", nl=False, err=False) with utils.maybe_spinner(opts): @@ -159,8 +161,11 @@ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument cloudsmith mcp configure --client cursor --local\n cloudsmith mcp configure # Auto-detect and configure all """ + # Get the profile from context + profile = ctx.meta.get("profile") + # Determine the best command to run the MCP server - server_config = _get_server_config() + server_config = _get_server_config(profile) clients_to_configure = [] if client: @@ -180,7 +185,7 @@ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument success_count = 0 for client_name in clients_to_configure: try: - if configure_client(client_name, server_config, is_global): + if configure_client(client_name, server_config, is_global, profile): click.echo( click.style(f"✓ Configured {client_name.title()}", fg="green") ) @@ -211,46 +216,44 @@ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument click.echo(click.style("\n✗ No clients were configured successfully", fg="red")) -def _get_server_config(): +def _get_server_config(profile=None): """Determine the best command configuration to run the MCP server.""" # Check if running in a virtual environment in_venv = hasattr(sys, "real_prefix") or ( hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix ) + # Build the base args + base_args = [] + if profile: + base_args.extend(["-P", profile]) + # In a venv, always use python -m to ensure we use the venv's packages if in_venv: return { "command": sys.executable, - "args": ["-m", "cloudsmith_cli", "mcp", "start"], + "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"], } # Otherwise, try to find cloudsmith in PATH, fall back to python -m cloudsmith_cmd = shutil.which("cloudsmith") if cloudsmith_cmd: - return {"command": cloudsmith_cmd, "args": ["mcp", "start"]} + return {"command": cloudsmith_cmd, "args": base_args + ["mcp", "start"]} - return {"command": sys.executable, "args": ["-m", "cloudsmith_cli", "mcp", "start"]} + return { + "command": sys.executable, + "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"], + } def detect_available_clients(): """Detect which MCP clients are available on the system.""" available = [] - # Check for Claude Desktop - claude_config = get_config_path("claude", is_global=True) - if claude_config and claude_config.parent.exists(): - available.append("claude") - - # Check for Cursor - cursor_config = get_config_path("cursor", is_global=True) - if cursor_config and cursor_config.parent.exists(): - available.append("cursor") - - # Check for VS Code - vscode_config = get_config_path("vscode", is_global=True) - if vscode_config and vscode_config.parent.exists(): - available.append("vscode") + for client in SUPPORTED_MCP_CLIENTS: + config = get_config_path(client, is_global=True) + if config and config.parent.exists(): + available.append(client) return available @@ -308,7 +311,7 @@ def get_config_path(client_name, is_global=True): return client_config.get(platform) -def configure_client(client_name, server_config, is_global=True): +def configure_client(client_name, server_config, is_global=True, profile=None): """Configure a specific MCP client with the Cloudsmith server.""" config_path = get_config_path(client_name, is_global) @@ -328,17 +331,20 @@ def configure_client(client_name, server_config, is_global=True): else: config = {} + # Determine server name based on profile + server_name = f"cloudsmith-{profile}" if profile else "cloudsmith" + # Add Cloudsmith MCP server based on client format if client_name in {"claude", "cursor"}: if "mcpServers" not in config: config["mcpServers"] = {} - config["mcpServers"]["cloudsmith"] = server_config + config["mcpServers"][server_name] = server_config elif client_name == "vscode": # VS Code uses a different format in settings.json if "chat.mcp.servers" not in config: config["chat.mcp.servers"] = {} - config["chat.mcp.servers"]["cloudsmith"] = server_config + config["chat.mcp.servers"][server_name] = server_config # Write updated config with open(config_path, "w") as f: diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index fdb9ae19..9996a4cf 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -64,6 +64,8 @@ class Default(SectionSchema): api_proxy = ConfigParam(name="api_ssl_verify", type=str) api_ssl_verify = ConfigParam(name="api_ssl_verify", type=bool, default=True) api_user_agent = ConfigParam(name="api_user_agent", type=str) + mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str) + mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str) @matches_section("profile:*") class Profile(Default): @@ -348,6 +350,35 @@ def debug(self, value): """Set value for debug flag.""" self._set_option("debug", bool(value)) + @property + def mcp_allowed_tools(self): + """Get value for Allowed MCP Tools.""" + return self._get_option("mcp_allowed_tools") + + @mcp_allowed_tools.setter + def mcp_allowed_tools(self, value): + """Set value for Allowed MCP Tools.""" + + if not value: + return + tools = value.split(",") + + return self._set_option("mcp_allowed_tools", tools) + + @property + def mcp_allowed_tool_groups(self): + """Get value for Allowed MCP Tool Groups.""" + return self._get_option("mcp_allowed_tool_groups") + + @mcp_allowed_tool_groups.setter + def mcp_allowed_tool_groups(self, value): + """Set value for Allowed MCP Tool Groups.""" + if not value: + return + tool_groups = value.split(",") + + return self._set_option("mcp_allowed_tool_groups", tool_groups) + @property def output(self): """Get value for output format.""" diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 46eb846a..872808a8 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -92,9 +92,15 @@ def common_cli_config_options(f): def wrapper(ctx, *args, **kwargs): # pylint: disable=missing-docstring opts = config.get_or_create_options(ctx) - profile = kwargs.pop("profile") - config_file = kwargs.pop("config_file") - creds_file = kwargs.pop("credentials_file") + profile = kwargs.pop("profile") or ctx.meta.get("profile") + config_file = kwargs.pop("config_file") or ctx.meta.get("config_file") + creds_file = kwargs.pop("credentials_file") or ctx.meta.get("creds_file") + + # Store in context for subcommands to inherit + ctx.meta["profile"] = profile + ctx.meta["config_file"] = config_file + ctx.meta["creds_file"] = creds_file + opts.load_config_file(path=config_file, profile=profile) opts.load_creds_file(path=creds_file, profile=profile) kwargs["opts"] = opts @@ -313,14 +319,28 @@ def call_print_rate_limit_info_with_opts(rate_info): def initialise_mcp(f): + @click.option( + "-a", + "--all-tools", + default=False, + is_flag=True, + help="Show all tools", + ) @click.pass_context @functools.wraps(f) def wrapper(ctx, *args, **kwargs): opts = kwargs.get("opts") + + print(opts.__dict__) + + all_tools = kwargs.pop("all_tools") + mcp_server = server.DynamicMCPServer( - api_base_url=opts.api_config.host, - api_token=opts.api_key, + api_config=opts.api_config, debug_mode=opts.debug, + allowed_tool_groups=opts.mcp_allowed_tool_groups, + allowed_tools=opts.mcp_allowed_tools, + force_all_tools=all_tools, ) kwargs["mcp_server"] = mcp_server return ctx.invoke(f, *args, **kwargs) diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 33436c88..2c76707e 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -4,6 +4,7 @@ import json from typing import Any, Dict, List, Optional +import cloudsmith_api import httpx from mcp import types from mcp.server.fastmcp import FastMCP @@ -65,11 +66,6 @@ "repo_retention", ] -DEFAULT_DISABLED_TOOLS = [ - "namespaces_read", - "orgs_read", -] - class CustomFastMCP(FastMCP): """Custom FastMCP that overrides tool listing to clean up schemas to not overwhelm the LLM context""" @@ -175,22 +171,25 @@ class DynamicMCPServer: def __init__( self, name: str = "Cloudsmith MCP Server", - api_base_url: str = "", - api_token: str = "", + api_config: cloudsmith_api.Configuration = None, use_toon=True, allow_destructive_tools=False, debug_mode=False, - disabled_tool_groups: Optional[List[str]] = DEFAULT_DISABLED_CATEGORIES, + allowed_tool_groups: Optional[List[str]] = None, + allowed_tools: Optional[List[str]] = None, + force_all_tools: bool = False, ): mcp_kwargs = {"log_level": "ERROR"} if debug_mode: mcp_kwargs["log_level"] = "DEBUG" self.mcp = CustomFastMCP(name, **mcp_kwargs) - self.api_base_url = api_base_url - self.api_token = api_token + self.api_config = api_config + self.api_base_url = api_config.host self.use_toon = use_toon self.allow_destructive_tools = allow_destructive_tools - self.disabled_tool_groups = set(disabled_tool_groups or []) + self.allowed_tool_groups = set(allowed_tool_groups or []) + self.allowed_tools = set(allowed_tools or []) + self.force_all_tools = force_all_tools self.tools: Dict[str, OpenAPITool] = {} async def load_openapi_spec(self): @@ -199,9 +198,9 @@ async def load_openapi_spec(self): if not self.api_base_url: raise Exception("The Cloudsmith API has to be set") - async with create_mcp_http_client() as http_client: - http_client = httpx.AsyncClient(timeout=30.0) - + async with create_mcp_http_client( + timeout=30.0, headers=self._get_additional_headers() + ) as http_client: for version, endpoint in API_VERSIONS_TO_DISCOVER.items(): spec_url = f"{self.api_base_url}/{version}/{endpoint}" response = await http_client.get(spec_url) @@ -255,18 +254,25 @@ def _get_tool_groups(self, tool_name: str) -> List[str]: def _is_tool_allowed(self, tool_name: str) -> bool: """Check if a tool is allowed based on user configuration""" + if self.force_all_tools: + return True + # Check if tool is destructive and destructive tools are disabled if not self.allow_destructive_tools and any( suffix in tool_name for suffix in TOOL_DELETE_SUFFIXES ): return False - # Check if any of the tool's groups are disabled tool_groups = self._get_tool_groups(tool_name) - if any(group in self.disabled_tool_groups for group in tool_groups): - return False - return True + # If user provided their own list of allowed tools or tool groups + if len(self.allowed_tools) > 0 or len(self.allowed_tool_groups) > 0: + allowed_tool_group = set(tool_groups).issubset(self.allowed_tool_groups) + allowed_tool = tool_name in self.allowed_tools + return allowed_tool or allowed_tool_group + + # Otherwise disable all categories in the default list + return not any(group in DEFAULT_DISABLED_CATEGORIES for group in tool_groups) async def _generate_tools_from_spec(self): """Generate MCP tools from OpenAPI specification""" @@ -344,7 +350,7 @@ async def dynamic_tool_func(**kwargs) -> str: param_schema.get("type", "string") ) - # TODO: Refactor this to not need the conditional, just use kwargs + # TODO: Refactor this to not need the conditional if param_name not in api_tool.parameters.get("required", []): # Create parameter with default value default = param_schema.get("default", None) @@ -393,17 +399,30 @@ def _schema_type_to_python_type(self, schema_type: str): } return type_mapping.get(schema_type, str) + def _get_additional_headers(self): + headers = {} + if "X-Api-Key" in self.api_config.api_key: + headers["X-Api-Key"] = self.api_config.api_key["X-Api-Key"] + + if self.api_config.headers: + headers.update(self.api_config.headers) + + return headers + async def _execute_api_call( self, tool: OpenAPITool, arguments: Dict[str, Any] ) -> str: """Execute an API call based on tool definition""" - http_client = create_mcp_http_client( - headers={ - "X-Api-Key": self.api_token, + + headers = self._get_additional_headers() + headers.update( + { "Accept": "application/json", } ) + http_client = create_mcp_http_client(headers=headers) + # Build URL with path parameters url = tool.base_url + tool.path path_params = {} From 788ffa560d968e75383d1ca21a8c16314c7c7a13 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Mon, 8 Dec 2025 16:12:52 +0000 Subject: [PATCH 05/12] chore: lint --- .flake8 | 2 +- .pylintrc | 2 +- cloudsmith_cli/cli/decorators.py | 2 - cloudsmith_cli/core/mcp/server.py | 73 +++++++++++++++++-------------- 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/.flake8 b/.flake8 index 1d18705c..e5a184c9 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] exclude = .svn,CVS,.bzr,.hg,.git,__pycache,.venv,migrations,settings,tests,.tox,build -max-complexity = 20 +max-complexity = 22 max-line-length = 100 select = C,E,F,W,B,B950 ignore = E203,E501,D107,D102,W503 diff --git a/.pylintrc b/.pylintrc index 2ccd33f7..869fae91 100644 --- a/.pylintrc +++ b/.pylintrc @@ -288,7 +288,7 @@ ignored-parents= max-args=5 # Maximum number of attributes for a class (see R0902). -max-attributes=7 +max-attributes=10 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 872808a8..25408758 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -331,8 +331,6 @@ def initialise_mcp(f): def wrapper(ctx, *args, **kwargs): opts = kwargs.get("opts") - print(opts.__dict__) - all_tools = kwargs.pop("all_tools") mcp_server = server.DynamicMCPServer( diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 2c76707e..41f9cd79 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -6,10 +6,10 @@ import cloudsmith_api import httpx +import toon_python as toon from mcp import types from mcp.server.fastmcp import FastMCP from mcp.shared._httpx_utils import create_mcp_http_client -from toon_python import encode from .data import OpenAPITool @@ -66,6 +66,8 @@ "repo_retention", ] +SERVER_NAME = "Cloudsmith MCP Server" + class CustomFastMCP(FastMCP): """Custom FastMCP that overrides tool listing to clean up schemas to not overwhelm the LLM context""" @@ -170,7 +172,6 @@ class DynamicMCPServer: def __init__( self, - name: str = "Cloudsmith MCP Server", api_config: cloudsmith_api.Configuration = None, use_toon=True, allow_destructive_tools=False, @@ -182,7 +183,7 @@ def __init__( mcp_kwargs = {"log_level": "ERROR"} if debug_mode: mcp_kwargs["log_level"] = "DEBUG" - self.mcp = CustomFastMCP(name, **mcp_kwargs) + self.mcp = CustomFastMCP(SERVER_NAME, **mcp_kwargs) self.api_config = api_config self.api_base_url = api_config.host self.use_toon = use_toon @@ -191,6 +192,7 @@ def __init__( self.allowed_tools = set(allowed_tools or []) self.force_all_tools = force_all_tools self.tools: Dict[str, OpenAPITool] = {} + self.spec = {} async def load_openapi_spec(self): """Load OpenAPI spec and generate tools dynamically""" @@ -409,23 +411,11 @@ def _get_additional_headers(self): return headers - async def _execute_api_call( - self, tool: OpenAPITool, arguments: Dict[str, Any] - ) -> str: - """Execute an API call based on tool definition""" - - headers = self._get_additional_headers() - headers.update( - { - "Accept": "application/json", - } - ) - - http_client = create_mcp_http_client(headers=headers) + def _get_request_params( + self, url: str, tool: OpenAPITool, arguments: Dict[str, Any] + ): + """Get params to use for HTTP request based on tool arguments""" - # Build URL with path parameters - url = tool.base_url + tool.path - path_params = {} query_params = {} body_params = {} @@ -456,8 +446,7 @@ async def _execute_api_call( for key, value in validated_arguments.items(): if key in properties: if "{" + key + "}" in url: - # Path parameter - path_params[key] = value + # This is a parameter as part of the URL, so replace it url = url.replace("{" + key + "}", str(value)) elif tool.method in ["GET", "DELETE"]: # Query parameter for GET/DELETE @@ -466,6 +455,27 @@ async def _execute_api_call( # Body parameter for POST/PUT/PATCH body_params[key] = value + return url, query_params, body_params + + async def _execute_api_call( + self, tool: OpenAPITool, arguments: Dict[str, Any] + ) -> str: + """Execute an API call based on tool definition""" + + headers = self._get_additional_headers() + headers.update( + { + "Accept": "application/json", + } + ) + + http_client = create_mcp_http_client(headers=headers) + + # Build URL with path parameters + url = tool.base_url + tool.path + + url, query_params, body_params = self._get_request_params(url, tool, arguments) + if tool.query_filter: parsed_simplified_filter = { k: v @@ -498,18 +508,15 @@ async def _execute_api_call( response.raise_for_status() # Return formatted response - try: - result = response.json() - if self.use_toon: - return encode(result) - return json.dumps(result, indent=2) - except Exception: - return response.text + result = response.json() + if self.use_toon: + return toon.encode(result) + return json.dumps(result, indent=2) + except (json.JSONDecodeError, toon.ToonEncodingError): + return response.text except httpx.HTTPError as e: return f"HTTP error: {str(e)}" - except Exception as e: - return f"Error executing API call: {str(e)}" finally: await http_client.aclose() @@ -676,8 +683,8 @@ def _format_enum_description( if original_description: return f"{original_description}\n\nAllowed values:\n{enum_list}" - else: - return f"Allowed values:\n{enum_list}" + + return f"Allowed values:\n{enum_list}" def _resolve_schema_ref(self, ref_string: str) -> Dict[str, Any]: """ @@ -763,7 +770,7 @@ def list_groups(self) -> Dict[str, List[str]]: # Build a mapping of group -> list of tools groups: Dict[str, List[str]] = {} - for tool_name in self.tools.keys(): + for tool_name in self.tools: tool_groups = self._get_tool_groups(tool_name) for group in tool_groups: if group not in groups: From eef2b6d0d779c84ef83f7c8e61252f8b3765ddbd Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Mon, 8 Dec 2025 16:54:59 +0000 Subject: [PATCH 06/12] fix: bump python version to 3.10 --- .circleci/config.yml | 7 +--- requirements.in | 2 +- requirements.txt | 79 +++++++++----------------------------------- setup.py | 2 +- 4 files changed, 18 insertions(+), 72 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2040abd9..104001ea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -83,7 +83,7 @@ orbs: executors: cloudsmith_executor: docker: - - image: circleci/python:3.9 + - image: circleci/python:3.10 workflows: @@ -115,11 +115,6 @@ workflows: service_name: pytest command: pytest --junitxml ./reports/pytest.xml is_test_suite: true - - python/test: - name: pytest-python3.10 - version: "3.10" - pkg-manager: pip - pip-dependency-file: requirements.txt - python/test: name: pytest-python3.11 version: "3.11" diff --git a/requirements.in b/requirements.in index 427dadc6..f64b889e 100644 --- a/requirements.in +++ b/requirements.in @@ -7,5 +7,5 @@ pip-tools pre-commit pylint pytest-cov -toon-python +toon-format mcp==1.9.1 diff --git a/requirements.txt b/requirements.txt index 6f90eda5..d1bf2029 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,6 @@ anyio==4.11.0 # starlette astroid==2.15.6 # via pylint -backports-tarfile==1.2.0 - # via jaraco-context build==0.10.0 # via pip-tools bump2version==1.0.1 @@ -28,41 +26,30 @@ certifi==2024.8.30 # httpx cfgv==3.3.1 # via pre-commit -charset-normalizer==3.3.2 - # via requests click==8.1.6 # via # pip-tools -click-configfile==0.2.3 - # via cloudsmith-cli (setup.py) -click-didyoumean==0.3.1 - # via cloudsmith-cli (setup.py) -click-spinner==0.1.10 - # via cloudsmith-cli (setup.py) -cloudsmith-api==2.0.22 - # via cloudsmith-cli (setup.py) -configparser==7.1.0 - # via click-configfile + # uvicorn coverage[toml]==7.2.7 # via pytest-cov dill==0.3.7 # via pylint distlib==0.3.7 # via virtualenv +exceptiongroup==1.2.2 + # via + # anyio + # pytest filelock==3.12.2 # via virtualenv +freezegun==1.5.1 + # via -r requirements.in h11==0.16.0 # via # httpcore # uvicorn httpcore==1.0.9 # via httpx -exceptiongroup==1.2.2 - # via pytest -filelock==3.12.2 - # via virtualenv -freezegun==1.5.1 - # via -r requirements.in httpretty==1.1.4 # via -r requirements.in httpx==0.28.1 @@ -72,31 +59,19 @@ httpx-sse==0.4.3 identify==2.5.26 # via pre-commit idna==3.8 - # via requests -importlib-metadata==8.7.0 - # via keyring + # via + # anyio + # httpx iniconfig==2.0.0 # via pytest isort==5.12.0 # via pylint -jaraco-classes==3.4.0 - # via keyring -jaraco-context==6.0.1 - # via keyring -jaraco-functools==4.0.2 - # via keyring -keyring==25.4.1 - # via cloudsmith-cli (setup.py) lazy-object-proxy==1.9.0 # via astroid mccabe==0.7.0 # via pylint mcp==1.9.1 # via -r requirements.in -more-itertools==10.5.0 - # via - # jaraco-classes - # jaraco-functools nodeenv==1.8.0 # via pre-commit packaging==23.1 @@ -129,38 +104,22 @@ pytest==7.4.0 # via pytest-cov pytest-cov==4.1.0 # via -r requirements.in +python-dateutil==2.9.0.post0 + # via freezegun python-dotenv==1.1.1 # via pydantic-settings python-multipart==0.0.20 # via mcp pyyaml==6.0.1 # via pre-commit +six==1.16.0 + # via python-dateutil sniffio==1.3.1 # via anyio sse-starlette==3.0.2 # via mcp starlette==0.47.0 # via mcp -tomli==2.0.1 -python-dateutil==2.9.0.post0 - # via - # cloudsmith-api - # freezegun -pyyaml==6.0.1 - # via pre-commit -requests==2.32.3 - # via - # cloudsmith-cli (setup.py) - # requests-toolbelt -requests-toolbelt==1.0.0 - # via cloudsmith-cli (setup.py) -semver==3.0.2 - # via cloudsmith-cli (setup.py) -six==1.16.0 - # via - # click-configfile - # cloudsmith-api - # python-dateutil tomli==2.2.1 # via # build @@ -171,7 +130,7 @@ tomli==2.2.1 # pytest tomlkit==0.12.1 # via pylint -toon-python==0.1.2 +toon-format==0.1.0 # via -r requirements.in typing-extensions==4.13.2 # via @@ -182,20 +141,12 @@ typing-extensions==4.13.2 # uvicorn uvicorn==0.37.0 # via mcp - # pylint -urllib3==1.26.20 - # via - # cloudsmith-api - # cloudsmith-cli (setup.py) - # requests virtualenv==20.24.2 # via pre-commit wheel==0.41.1 # via pip-tools wrapt==1.15.0 # via astroid -zipp==3.21.0 - # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/setup.py b/setup.py index 56ec00e0..40c6b776 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ def get_long_description(): include_package_data=True, zip_safe=False, platforms=["any"], - python_requires=">=3.9.0", + python_requires=">=3.10.0", install_requires=[ "click>=8.2.0,!=8.3.0", "click-configfile>=0.2.3", From 0e51b28db9dd8d648666df0b1b85b4560b8a61cb Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Mon, 8 Dec 2025 17:03:27 +0000 Subject: [PATCH 07/12] chore: dependency hell --- requirements.in | 2 +- requirements.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.in b/requirements.in index f64b889e..427dadc6 100644 --- a/requirements.in +++ b/requirements.in @@ -7,5 +7,5 @@ pip-tools pre-commit pylint pytest-cov -toon-format +toon-python mcp==1.9.1 diff --git a/requirements.txt b/requirements.txt index d1bf2029..6490c848 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ anyio==4.11.0 # starlette astroid==2.15.6 # via pylint -build==0.10.0 +build==1.3.0 # via pip-tools bump2version==1.0.1 # via bumpversion @@ -78,7 +78,7 @@ packaging==23.1 # via # build # pytest -pip-tools==7.2.0 +pip-tools==7.5.2 # via -r requirements.in platformdirs==3.10.0 # via @@ -130,7 +130,7 @@ tomli==2.2.1 # pytest tomlkit==0.12.1 # via pylint -toon-format==0.1.0 +toon-python==0.1.2 # via -r requirements.in typing-extensions==4.13.2 # via From 0d89c06a1dd28c3cab87ea3fae1a1e62a931fffe Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Mon, 8 Dec 2025 17:06:19 +0000 Subject: [PATCH 08/12] chore: dependency hell --- cloudsmith_cli/cli/decorators.py | 2 +- requirements.txt | 64 ++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 66f39d6b..39a797af 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -8,7 +8,7 @@ from ..core.api.init import initialise_api as _initialise_api from ..core.mcp import server -from . import config, utils, validators +from . import config, utils def report_retry(seconds, context=None): diff --git a/requirements.txt b/requirements.txt index 6490c848..5ce5b4ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile +# pip-compile --output-file=requirements.txt --pre requirements.in setup.py # annotated-types==0.7.0 # via pydantic @@ -14,6 +14,8 @@ anyio==4.11.0 # starlette astroid==2.15.6 # via pylint +backports-tarfile==1.2.0 + # via jaraco-context build==1.3.0 # via pip-tools bump2version==1.0.1 @@ -22,14 +24,31 @@ bumpversion==0.6.0 # via -r requirements.in certifi==2024.8.30 # via + # cloudsmith-api # httpcore # httpx + # requests cfgv==3.3.1 # via pre-commit +charset-normalizer==3.4.4 + # via requests click==8.1.6 # via + # click-configfile + # click-didyoumean + # cloudsmith-cli (setup.py) # pip-tools # uvicorn +click-configfile==0.2.3 + # via cloudsmith-cli (setup.py) +click-didyoumean==0.3.1 + # via cloudsmith-cli (setup.py) +click-spinner==0.1.10 + # via cloudsmith-cli (setup.py) +cloudsmith-api==2.0.22 + # via cloudsmith-cli (setup.py) +configparser==7.2.0 + # via click-configfile coverage[toml]==7.2.7 # via pytest-cov dill==0.3.7 @@ -62,16 +81,31 @@ idna==3.8 # via # anyio # httpx + # requests +importlib-metadata==8.7.0 + # via keyring iniconfig==2.0.0 # via pytest isort==5.12.0 # via pylint +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.3.0 + # via keyring +keyring==25.7.0 + # via cloudsmith-cli (setup.py) lazy-object-proxy==1.9.0 # via astroid mccabe==0.7.0 # via pylint mcp==1.9.1 # via -r requirements.in +more-itertools==10.8.0 + # via + # jaraco-classes + # jaraco-functools nodeenv==1.8.0 # via pre-commit packaging==23.1 @@ -99,21 +133,36 @@ pydantic-settings==2.8.1 pylint==3.0.0a6 # via -r requirements.in pyproject-hooks==1.0.0 - # via build + # via + # build + # pip-tools pytest==7.4.0 # via pytest-cov pytest-cov==4.1.0 # via -r requirements.in python-dateutil==2.9.0.post0 - # via freezegun + # via + # cloudsmith-api + # freezegun python-dotenv==1.1.1 # via pydantic-settings python-multipart==0.0.20 # via mcp pyyaml==6.0.1 # via pre-commit +requests==2.32.5 + # via + # cloudsmith-cli (setup.py) + # requests-toolbelt +requests-toolbelt==1.0.0 + # via cloudsmith-cli (setup.py) +semver==3.0.4 + # via cloudsmith-cli (setup.py) six==1.16.0 - # via python-dateutil + # via + # click-configfile + # cloudsmith-api + # python-dateutil sniffio==1.3.1 # via anyio sse-starlette==3.0.2 @@ -139,6 +188,11 @@ typing-extensions==4.13.2 # pydantic # pydantic-core # uvicorn +urllib3==1.26.20 + # via + # cloudsmith-api + # cloudsmith-cli (setup.py) + # requests uvicorn==0.37.0 # via mcp virtualenv==20.24.2 @@ -147,6 +201,8 @@ wheel==0.41.1 # via pip-tools wrapt==1.15.0 # via astroid +zipp==3.23.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip From a086bcef8f573dadf9482138b6f220aea6b62da1 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Mon, 8 Dec 2025 19:30:56 +0000 Subject: [PATCH 09/12] fix: bug with filtering --- cloudsmith_cli/core/mcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 41f9cd79..bf1ecaa2 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -269,7 +269,7 @@ def _is_tool_allowed(self, tool_name: str) -> bool: # If user provided their own list of allowed tools or tool groups if len(self.allowed_tools) > 0 or len(self.allowed_tool_groups) > 0: - allowed_tool_group = set(tool_groups).issubset(self.allowed_tool_groups) + allowed_tool_group = bool(set(tool_groups) & set(self.allowed_tool_groups)) allowed_tool = tool_name in self.allowed_tools return allowed_tool or allowed_tool_group From 7fd9c0b8344ebd6cbd52fc4529c2f8b4b5bbb623 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Tue, 16 Dec 2025 10:01:45 +0000 Subject: [PATCH 10/12] fix: linting --- .pylintrc | 2 +- cloudsmith_cli/cli/commands/mcp.py | 14 ++++++++------ cloudsmith_cli/core/mcp/data.py | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.pylintrc b/.pylintrc index c1086417..ccbbd4bb 100644 --- a/.pylintrc +++ b/.pylintrc @@ -299,7 +299,7 @@ max-locals=15 max-parents=7 # Maximum number of public methods for a class (see R0904). -max-public-methods=20 +max-public-methods=22 # Maximum number of return / yield for function / method body. max-returns=6 diff --git a/cloudsmith_cli/cli/commands/mcp.py b/cloudsmith_cli/cli/commands/mcp.py index f4ce4b26..76cb1e9c 100644 --- a/cloudsmith_cli/cli/commands/mcp.py +++ b/cloudsmith_cli/cli/commands/mcp.py @@ -271,9 +271,11 @@ def get_config_path(client_name, is_global=True): / "Application Support" / "Claude" / "claude_desktop_config.json", - "win32": Path(appdata) / "Claude" / "claude_desktop_config.json" - if appdata - else None, + "win32": ( + Path(appdata) / "Claude" / "claude_desktop_config.json" + if appdata + else None + ), "linux": home / ".config" / "Claude" / "claude_desktop_config.json", }, "cursor": { @@ -287,9 +289,9 @@ def get_config_path(client_name, is_global=True): / "Code" / "User" / "settings.json", - "win32": Path(appdata) / "Code" / "User" / "settings.json" - if appdata - else None, + "win32": ( + Path(appdata) / "Code" / "User" / "settings.json" if appdata else None + ), "linux": home / ".config" / "Code" / "User" / "settings.json", "local": Path.cwd() / ".vscode" / "settings.json", }, diff --git a/cloudsmith_cli/core/mcp/data.py b/cloudsmith_cli/core/mcp/data.py index 95694c3a..a6352acc 100644 --- a/cloudsmith_cli/core/mcp/data.py +++ b/cloudsmith_cli/core/mcp/data.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict +from typing import Any, Dict, Optional import httpx @@ -14,7 +14,7 @@ class OpenAPITool: path: str parameters: Dict[str, Any] base_url: str - query_filter: str | None + query_filter: Optional[str] @dataclass From 9c31d670f41de7b64db46f9329854d1020b3b952 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Tue, 16 Dec 2025 10:03:09 +0000 Subject: [PATCH 11/12] Update cloudsmith_cli/core/mcp/server.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cloudsmith_cli/core/mcp/server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index bf1ecaa2..73d6f765 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -282,8 +282,6 @@ async def _generate_tools_from_spec(self): if not self.spec: raise ValueError("OpenAPI spec not loaded") - # print(f"Generating tools for base URL: {self.api_base_url}") - # Parse paths and generate tools for path, path_item in self.spec.get("paths", {}).items(): for method, operation in path_item.items(): From bfe6d6f34afe48067ff37dabd18351ec6752a8e0 Mon Sep 17 00:00:00 2001 From: Esteban Garcia Date: Tue, 16 Dec 2025 10:04:56 +0000 Subject: [PATCH 12/12] fix: cleanup --- cloudsmith_cli/cli/config.py | 4 ++-- cloudsmith_cli/core/mcp/data.py | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index b4b375d6..b3043100 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -363,7 +363,7 @@ def mcp_allowed_tools(self, value): return tools = value.split(",") - return self._set_option("mcp_allowed_tools", tools) + self._set_option("mcp_allowed_tools", tools) @property def mcp_allowed_tool_groups(self): @@ -377,7 +377,7 @@ def mcp_allowed_tool_groups(self, value): return tool_groups = value.split(",") - return self._set_option("mcp_allowed_tool_groups", tool_groups) + self._set_option("mcp_allowed_tool_groups", tool_groups) @property def output(self): diff --git a/cloudsmith_cli/core/mcp/data.py b/cloudsmith_cli/core/mcp/data.py index a6352acc..996062e7 100644 --- a/cloudsmith_cli/core/mcp/data.py +++ b/cloudsmith_cli/core/mcp/data.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -import httpx - @dataclass class OpenAPITool: @@ -15,10 +13,3 @@ class OpenAPITool: parameters: Dict[str, Any] base_url: str query_filter: Optional[str] - - -@dataclass -class AppContext: - """Application context for storing OpenAPI tools and HTTP client""" - - http_client: httpx.AsyncClient