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/.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 d0167a24..ccbbd4bb 100644 --- a/.pylintrc +++ b/.pylintrc @@ -284,7 +284,7 @@ ignored-parents= max-args=13 # 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 @@ -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/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index ecca6433..0ae32b3e 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -12,6 +12,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..76cb1e9c --- /dev/null +++ b/cloudsmith_cli/cli/commands/mcp.py @@ -0,0 +1,355 @@ +"""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 + +SUPPORTED_MCP_CLIENTS = ["claude", "cursor", "vscode"] + + +@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 MCP Client + """ + click.echo("Getting list of tools ... ", nl=False, err=False) + with utils.maybe_spinner(opts): + tools = mcp_server.list_tools() + + 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 tools 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) + + +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", + 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 + """ + # Get the profile from context + profile = ctx.meta.get("profile") + + # Determine the best command to run the MCP server + server_config = _get_server_config(profile) + + 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, profile): + 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 OSError 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(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"] + 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": base_args + ["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 = [] + + 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 + + +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, profile=None): + """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 = {} + + # 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"][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"][server_name] = 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/config.py b/cloudsmith_cli/cli/config.py index e7638f8e..b3043100 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_proxy", 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(",") + + 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(",") + + 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 4375f2d2..3c60edbc 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -7,6 +7,7 @@ from cloudsmith_cli.cli import validators from ..core.api.init import initialise_api as _initialise_api +from ..core.mcp import server from . import config, utils @@ -93,9 +94,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 @@ -330,3 +337,31 @@ def call_print_rate_limit_info_with_opts(rate_info): return ctx.invoke(f, *args, **kwargs) return wrapper + + +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") + + all_tools = kwargs.pop("all_tools") + + mcp_server = server.DynamicMCPServer( + 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) + + 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..996062e7 --- /dev/null +++ b/cloudsmith_cli/core/mcp/data.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@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 + query_filter: Optional[str] diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py new file mode 100644 index 00000000..73d6f765 --- /dev/null +++ b/cloudsmith_cli/core/mcp/server.py @@ -0,0 +1,779 @@ +import asyncio +import copy +import inspect +import json +from typing import Any, Dict, List, Optional + +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 .data import OpenAPITool + +ALLOWED_METHODS = ["get", "post", "put", "delete", "patch"] + +API_VERSIONS_TO_DISCOVER = { + "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", +] + +SERVER_NAME = "Cloudsmith MCP Server" + + +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, + api_config: cloudsmith_api.Configuration = None, + use_toon=True, + allow_destructive_tools=False, + debug_mode=False, + 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(SERVER_NAME, **mcp_kwargs) + 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.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] = {} + self.spec = {} + + 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( + 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) + 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 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 + + tool_groups = self._get_tool_groups(tool_name) + + # 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 = bool(set(tool_groups) & set(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""" + + if not self.spec: + raise ValueError("OpenAPI spec not loaded") + + # 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 + 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) + + 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 + + def _get_request_params( + self, url: str, tool: OpenAPITool, arguments: Dict[str, Any] + ): + """Get params to use for HTTP request based on tool arguments""" + + 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: + # 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 + query_params[key] = value + else: + # 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 + 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) + 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 + 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)}" + 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()) + + simplified_query = operation.get("x-simplified") + + # 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, + query_filter=simplified_query, + ) + + 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}" + + 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 + + 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: + 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 69e9a5a6..427dadc6 100644 --- a/requirements.in +++ b/requirements.in @@ -7,3 +7,5 @@ pip-tools pre-commit pylint pytest-cov +toon-python +mcp==1.9.1 diff --git a/requirements.txt b/requirements.txt index 20f45cba..fc2c2720 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,22 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# 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 # +annotated-types==0.7.0 + # via pydantic +anyio==4.11.0 + # via + # httpx + # mcp + # sse-starlette + # starlette astroid==2.15.6 # via pylint backports-tarfile==1.2.0 # via jaraco-context -build==0.10.0 +build==1.3.0 # via pip-tools bump2version==1.0.1 # via bumpversion @@ -17,10 +25,12 @@ bumpversion==0.6.0 certifi==2024.8.30 # via # cloudsmith-api + # httpcore + # httpx # requests cfgv==3.3.1 # via pre-commit -charset-normalizer==3.3.2 +charset-normalizer==3.4.4 # via requests click==8.1.6 # via @@ -28,6 +38,7 @@ click==8.1.6 # click-didyoumean # cloudsmith-cli (setup.py) # pip-tools + # uvicorn click-configfile==0.2.3 # via cloudsmith-cli (setup.py) click-didyoumean==0.3.1 @@ -36,7 +47,7 @@ click-spinner==0.1.10 # via cloudsmith-cli (setup.py) cloudsmith-api==2.0.22 # via cloudsmith-cli (setup.py) -configparser==7.1.0 +configparser==7.2.0 # via click-configfile coverage[toml]==7.2.7 # via pytest-cov @@ -45,17 +56,32 @@ dill==0.3.7 distlib==0.3.7 # via virtualenv exceptiongroup==1.2.2 - # via pytest + # 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 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.8 - # via requests + # via + # anyio + # httpx + # requests importlib-metadata==8.7.0 # via keyring iniconfig==2.0.0 @@ -66,15 +92,17 @@ jaraco-classes==3.4.0 # via keyring jaraco-context==6.0.1 # via keyring -jaraco-functools==4.0.2 +jaraco-functools==4.3.0 # via keyring -keyring==25.4.1 +keyring==25.7.0 # via cloudsmith-cli (setup.py) lazy-object-proxy==1.9.0 # via astroid mccabe==0.7.0 # via pylint -more-itertools==10.5.0 +mcp==1.9.1 + # via -r requirements.in +more-itertools==10.8.0 # via # jaraco-classes # jaraco-functools @@ -84,7 +112,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 @@ -94,10 +122,20 @@ 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 - # via build + # via + # build + # pip-tools pytest==7.4.0 # via pytest-cov pytest-cov==4.1.0 @@ -106,21 +144,31 @@ python-dateutil==2.9.0.post0 # 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.3 +requests==2.32.5 # via # cloudsmith-cli (setup.py) # requests-toolbelt requests-toolbelt==1.0.0 # via cloudsmith-cli (setup.py) -semver==3.0.2 +semver==3.0.4 # 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.2.1 # via # build @@ -131,8 +179,11 @@ tomli==2.2.1 # pytest tomlkit==0.12.1 # via pylint +toon-python==0.1.2 + # via -r requirements.in typing-extensions==4.13.2 # via + # anyio # astroid # pylint urllib3==2.5.0 @@ -140,13 +191,15 @@ urllib3==2.5.0 # cloudsmith-api # cloudsmith-cli (setup.py) # requests +uvicorn==0.37.0 + # via mcp virtualenv==20.24.2 # via pre-commit wheel==0.41.1 # via pip-tools wrapt==1.15.0 # via astroid -zipp==3.21.0 +zipp==3.23.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/setup.py b/setup.py index aa23f929..db57aeb0 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.1.8,!=8.3.0", "click-configfile>=0.2.3",