cloudsmith-cli 1.10.3__py2.py3-none-any.whl → 1.11.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ from . import (
12
12
  help_,
13
13
  list_,
14
14
  login,
15
+ mcp,
15
16
  metrics,
16
17
  move,
17
18
  policy,
@@ -0,0 +1,424 @@
1
+ """Main command/entrypoint."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Dict, List
9
+
10
+ import click
11
+
12
+ from ...core.mcp import server
13
+ from ...core.mcp.data import OpenAPITool
14
+ from .. import command, decorators, utils
15
+ from .main import main
16
+
17
+ SUPPORTED_MCP_CLIENTS = {
18
+ "claude": "Claude Desktop",
19
+ "cursor": "Cursor IDE",
20
+ "vscode": "VS Code",
21
+ "gemini-cli": "Gemini CLI",
22
+ }
23
+
24
+
25
+ @main.group(cls=command.AliasGroup, name="mcp")
26
+ @decorators.common_cli_config_options
27
+ @decorators.common_cli_output_options
28
+ @decorators.common_api_auth_options
29
+ @decorators.initialise_api
30
+ @click.pass_context
31
+ def mcp_(ctx, opts): # pylint: disable=unused-argument
32
+ """
33
+ Start the Cloudsmith MCP Server
34
+
35
+ See the help for subcommands for more information on each.
36
+ """
37
+
38
+
39
+ @mcp_.command(name="start")
40
+ @decorators.initialise_api
41
+ @decorators.initialise_mcp
42
+ @click.pass_context
43
+ def start(ctx, opts, mcp_server: server.DynamicMCPServer):
44
+ """
45
+ Start the MCP Server
46
+ """
47
+ mcp_server.run()
48
+
49
+
50
+ @mcp_.command(name="list_tools")
51
+ @decorators.common_cli_config_options
52
+ @decorators.common_cli_output_options
53
+ @decorators.common_api_auth_options
54
+ @decorators.initialise_api
55
+ @decorators.initialise_mcp
56
+ @click.pass_context
57
+ def list_tools(ctx, opts, mcp_server: server.DynamicMCPServer):
58
+ """
59
+ List available tools that will be exposed to the MCP Client
60
+ """
61
+ use_stderr = utils.should_use_stderr(opts)
62
+
63
+ if not use_stderr:
64
+ click.echo("Getting list of tools ... ", nl=False, err=use_stderr)
65
+
66
+ with utils.maybe_spinner(opts):
67
+ tools = mcp_server.list_tools()
68
+
69
+ if not use_stderr:
70
+ click.secho("OK", fg="green", err=use_stderr)
71
+
72
+ tools_data = [
73
+ {"name": name, "description": spec.description} for name, spec in tools.items()
74
+ ]
75
+
76
+ if utils.maybe_print_as_json(opts, tools_data):
77
+ return
78
+
79
+ print_tools(tools)
80
+
81
+
82
+ @mcp_.command(name="list_groups")
83
+ @decorators.common_cli_config_options
84
+ @decorators.common_cli_output_options
85
+ @decorators.common_api_auth_options
86
+ @decorators.initialise_api
87
+ @decorators.initialise_mcp
88
+ @click.pass_context
89
+ def list_groups(ctx, opts, mcp_server: server.DynamicMCPServer):
90
+ """
91
+ List available tool groups and the tools they contain
92
+ """
93
+ use_stderr = utils.should_use_stderr(opts)
94
+
95
+ if not use_stderr:
96
+ click.echo("Getting list of tool groups ... ", nl=False, err=use_stderr)
97
+
98
+ with utils.maybe_spinner(opts):
99
+ groups = mcp_server.list_groups()
100
+
101
+ if not use_stderr:
102
+ click.secho("OK", fg="green", err=use_stderr)
103
+
104
+ groups_data = [{"name": name, "tools": tools} for name, tools in groups.items()]
105
+
106
+ if utils.maybe_print_as_json(opts, groups_data):
107
+ return
108
+
109
+ print_groups(groups)
110
+
111
+
112
+ def print_tools(tool_list: Dict[str, OpenAPITool]):
113
+ """Print tools as a table or output in another format."""
114
+
115
+ headers = [
116
+ "Name",
117
+ "Description",
118
+ ]
119
+
120
+ rows = []
121
+ for tool_name, tools_spec in tool_list.items():
122
+ rows.append(
123
+ [
124
+ click.style(tool_name, fg="cyan"),
125
+ click.style(tools_spec.description, fg="yellow"),
126
+ ]
127
+ )
128
+
129
+ if tool_list:
130
+ click.echo()
131
+ utils.pretty_print_table(headers, rows)
132
+
133
+ click.echo()
134
+
135
+ num_results = len(tool_list)
136
+ list_suffix = "tool%s visible" % ("s" if num_results != 1 else "")
137
+ utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix)
138
+
139
+
140
+ def print_groups(group_list: Dict[str, List[str]]):
141
+ """Print tool groups as a table or output in another format."""
142
+
143
+ headers = [
144
+ "Group Name",
145
+ "Tool Count",
146
+ "Sample Tools",
147
+ ]
148
+
149
+ rows = []
150
+ for group_name, tools in group_list.items():
151
+ # Show first 3 tools as samples
152
+ sample_tools = ", ".join(tools[:3])
153
+ if len(tools) > 3:
154
+ sample_tools += f", ... (+{len(tools) - 3} more)"
155
+
156
+ rows.append(
157
+ [
158
+ click.style(group_name, fg="cyan"),
159
+ click.style(str(len(tools)), fg="yellow"),
160
+ click.style(sample_tools, fg="white"),
161
+ ]
162
+ )
163
+
164
+ if group_list:
165
+ click.echo()
166
+ utils.pretty_print_table(headers, rows)
167
+
168
+ click.echo()
169
+
170
+ num_results = len(group_list)
171
+ list_suffix = "group%s visible" % ("s" if num_results != 1 else "")
172
+ utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix)
173
+
174
+
175
+ @mcp_.command(name="configure")
176
+ @decorators.common_cli_config_options
177
+ @decorators.common_cli_output_options
178
+ @decorators.common_api_auth_options
179
+ @click.option(
180
+ "--client",
181
+ type=click.Choice(list(SUPPORTED_MCP_CLIENTS.keys()), case_sensitive=False),
182
+ help=f"MCP client to configure ({', '.join(SUPPORTED_MCP_CLIENTS.keys())}). If not specified, will attempt to detect and configure all.",
183
+ )
184
+ @click.option(
185
+ "--global/--local",
186
+ "is_global",
187
+ default=True,
188
+ help="Configure globally (default) or in current project directory (local)",
189
+ )
190
+ @decorators.initialise_api
191
+ @click.pass_context
192
+ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument
193
+ """
194
+ Configure the Cloudsmith MCP server for supported clients.
195
+
196
+ This command automatically adds the Cloudsmith MCP server configuration
197
+ to the specified client's configuration file. Supported clients are:
198
+ - Claude Desktop
199
+ - Cursor IDE
200
+ - VS Code (GitHub Copilot)
201
+ - Gemini CLI
202
+
203
+ Examples:\n
204
+ cloudsmith mcp configure --client claude\n
205
+ cloudsmith mcp configure --client cursor --local\n
206
+ cloudsmith mcp configure --client gemini-cli\n
207
+ cloudsmith mcp configure # Auto-detect and configure all
208
+ """
209
+
210
+ use_stderr = utils.should_use_stderr(opts)
211
+
212
+ # Get the profile from context
213
+ profile = ctx.meta.get("profile")
214
+
215
+ # Determine the best command to run the MCP server
216
+ server_config = _get_server_config(profile)
217
+
218
+ clients_to_configure = []
219
+ if client:
220
+ clients_to_configure = [client.lower()]
221
+ else:
222
+ # Auto-detect available clients
223
+ clients_to_configure = detect_available_clients()
224
+
225
+ if not clients_to_configure:
226
+ if not use_stderr:
227
+ click.echo(click.style("No supported MCP clients detected.", fg="yellow"))
228
+ click.echo("\nSupported clients:")
229
+ for display_name in SUPPORTED_MCP_CLIENTS.values():
230
+ click.echo(f" - {display_name}")
231
+
232
+ utils.maybe_print_as_json(opts, [])
233
+ return
234
+
235
+ results = []
236
+ success_count = 0
237
+ for client_name in clients_to_configure:
238
+ try:
239
+ if configure_client(client_name, server_config, is_global, profile):
240
+ if not use_stderr:
241
+ click.echo(
242
+ click.style(f"✓ Configured {client_name.title()}", fg="green")
243
+ )
244
+ success_count += 1
245
+ results.append({"client": client_name, "success": True})
246
+ else:
247
+ if not use_stderr:
248
+ click.echo(
249
+ click.style(
250
+ f"✗ Failed to configure {client_name.title()}", fg="red"
251
+ )
252
+ )
253
+ results.append(
254
+ {
255
+ "client": client_name,
256
+ "success": False,
257
+ "error": "Configuration failed",
258
+ }
259
+ )
260
+ except OSError as e:
261
+ if not use_stderr:
262
+ click.echo(
263
+ click.style(
264
+ f"✗ Error configuring {client_name.title()}: {str(e)}", fg="red"
265
+ )
266
+ )
267
+ results.append({"client": client_name, "success": False, "error": str(e)})
268
+
269
+ if utils.maybe_print_as_json(opts, results):
270
+ return
271
+
272
+ if success_count > 0:
273
+ click.echo(
274
+ click.style(
275
+ f"\n✓ Successfully configured {success_count} client(s)", fg="green"
276
+ )
277
+ )
278
+ click.echo(
279
+ "\nNote: You may need to restart the client application for changes to take effect."
280
+ )
281
+ else:
282
+ click.echo(click.style("\n✗ No clients were configured successfully", fg="red"))
283
+
284
+
285
+ def _get_server_config(profile=None):
286
+ """Determine the first available command configuration to run the MCP server."""
287
+ # Check if running in a virtual environment
288
+ in_venv = hasattr(sys, "real_prefix") or (
289
+ hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
290
+ )
291
+
292
+ # Build the base args
293
+ base_args = []
294
+ if profile:
295
+ base_args.extend(["-P", profile])
296
+
297
+ # In a venv, always use python -m to ensure we use the venv's packages
298
+ if in_venv:
299
+ return {
300
+ "command": sys.executable,
301
+ "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"],
302
+ }
303
+
304
+ # Otherwise, try to find cloudsmith in PATH, fall back to python -m
305
+ cloudsmith_cmd = shutil.which("cloudsmith")
306
+ if cloudsmith_cmd:
307
+ return {"command": cloudsmith_cmd, "args": base_args + ["mcp", "start"]}
308
+
309
+ return {
310
+ "command": sys.executable,
311
+ "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"],
312
+ }
313
+
314
+
315
+ def detect_available_clients():
316
+ """Detect which MCP clients are available on the system."""
317
+ available = []
318
+
319
+ for client in SUPPORTED_MCP_CLIENTS:
320
+ config = get_config_path(client, is_global=True)
321
+ if config and config.parent.exists():
322
+ available.append(client)
323
+
324
+ return available
325
+
326
+
327
+ def get_config_path(client_name, is_global=True):
328
+ """Get the configuration file path for a given client."""
329
+ home = Path.home()
330
+ appdata = os.getenv("APPDATA", "")
331
+
332
+ # Configuration paths by client, platform, and scope
333
+ config_paths = {
334
+ "claude": {
335
+ "darwin": home
336
+ / "Library"
337
+ / "Application Support"
338
+ / "Claude"
339
+ / "claude_desktop_config.json",
340
+ "win32": (
341
+ Path(appdata) / "Claude" / "claude_desktop_config.json"
342
+ if appdata
343
+ else None
344
+ ),
345
+ "linux": home / ".config" / "Claude" / "claude_desktop_config.json",
346
+ },
347
+ "cursor": {
348
+ "global": home / ".cursor" / "mcp.json",
349
+ "local": Path.cwd() / ".cursor" / "mcp.json",
350
+ },
351
+ "vscode": {
352
+ "darwin": home
353
+ / "Library"
354
+ / "Application Support"
355
+ / "Code"
356
+ / "User"
357
+ / "settings.json",
358
+ "win32": (
359
+ Path(appdata) / "Code" / "User" / "settings.json" if appdata else None
360
+ ),
361
+ "linux": home / ".config" / "Code" / "User" / "settings.json",
362
+ "local": Path.cwd() / ".vscode" / "settings.json",
363
+ },
364
+ "gemini-cli": {
365
+ "global": home / ".gemini" / "settings.json",
366
+ "local": Path.cwd() / ".gemini" / "settings.json",
367
+ },
368
+ }
369
+
370
+ client_config = config_paths.get(client_name, {})
371
+
372
+ # For Cursor and Gemini CLI, use global/local scope instead of platform
373
+ if client_name in ("cursor", "gemini-cli"):
374
+ scope = "global" if is_global else "local"
375
+ return client_config.get(scope)
376
+
377
+ # For VS Code local config
378
+ if client_name == "vscode" and not is_global:
379
+ return client_config.get("local")
380
+
381
+ # For platform-specific configs (Claude and VS Code global)
382
+ platform = sys.platform if sys.platform in ("darwin", "win32") else "linux"
383
+ return client_config.get(platform)
384
+
385
+
386
+ def configure_client(client_name, server_config, is_global=True, profile=None):
387
+ """Configure a specific MCP client with the Cloudsmith server."""
388
+ config_path = get_config_path(client_name, is_global)
389
+
390
+ if not config_path:
391
+ return False
392
+
393
+ # Ensure parent directory exists
394
+ config_path.parent.mkdir(parents=True, exist_ok=True)
395
+
396
+ # Read existing config or create new one
397
+ config = {}
398
+ if config_path.exists():
399
+ with open(config_path) as f:
400
+ try:
401
+ config = json.load(f)
402
+ except json.JSONDecodeError:
403
+ raise ValueError(f"Invalid JSON in config file: {config_path}")
404
+
405
+ # Determine server name based on profile
406
+ server_name = f"cloudsmith-{profile}" if profile else "cloudsmith"
407
+
408
+ # Add Cloudsmith MCP server based on client format
409
+ if client_name in {"claude", "cursor", "gemini-cli"}:
410
+ if "mcpServers" not in config:
411
+ config["mcpServers"] = {}
412
+ config["mcpServers"][server_name] = server_config
413
+
414
+ elif client_name == "vscode":
415
+ # VS Code uses a different format in settings.json
416
+ if "chat.mcp.servers" not in config:
417
+ config["chat.mcp.servers"] = {}
418
+ config["chat.mcp.servers"][server_name] = server_config
419
+
420
+ # Write updated config
421
+ with open(config_path, "w") as f:
422
+ json.dump(config, f, indent=2)
423
+
424
+ return True
@@ -64,6 +64,8 @@ class ConfigSchema:
64
64
  api_proxy = ConfigParam(name="api_proxy", type=str)
65
65
  api_ssl_verify = ConfigParam(name="api_ssl_verify", type=bool, default=True)
66
66
  api_user_agent = ConfigParam(name="api_user_agent", type=str)
67
+ mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str)
68
+ mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str)
67
69
 
68
70
  @matches_section("profile:*")
69
71
  class Profile(Default):
@@ -348,6 +350,35 @@ class Options:
348
350
  """Set value for debug flag."""
349
351
  self._set_option("debug", bool(value))
350
352
 
353
+ @property
354
+ def mcp_allowed_tools(self):
355
+ """Get value for Allowed MCP Tools."""
356
+ return self._get_option("mcp_allowed_tools")
357
+
358
+ @mcp_allowed_tools.setter
359
+ def mcp_allowed_tools(self, value):
360
+ """Set value for Allowed MCP Tools."""
361
+
362
+ if not value:
363
+ return
364
+ tools = value.split(",")
365
+
366
+ self._set_option("mcp_allowed_tools", tools)
367
+
368
+ @property
369
+ def mcp_allowed_tool_groups(self):
370
+ """Get value for Allowed MCP Tool Groups."""
371
+ return self._get_option("mcp_allowed_tool_groups")
372
+
373
+ @mcp_allowed_tool_groups.setter
374
+ def mcp_allowed_tool_groups(self, value):
375
+ """Set value for Allowed MCP Tool Groups."""
376
+ if not value:
377
+ return
378
+ tool_groups = value.split(",")
379
+
380
+ self._set_option("mcp_allowed_tool_groups", tool_groups)
381
+
351
382
  @property
352
383
  def output(self):
353
384
  """Get value for output format."""
@@ -7,6 +7,7 @@ import click
7
7
  from cloudsmith_cli.cli import validators
8
8
 
9
9
  from ..core.api.init import initialise_api as _initialise_api
10
+ from ..core.mcp import server
10
11
  from . import config, utils
11
12
 
12
13
 
@@ -93,9 +94,15 @@ def common_cli_config_options(f):
93
94
  def wrapper(ctx, *args, **kwargs):
94
95
  # pylint: disable=missing-docstring
95
96
  opts = config.get_or_create_options(ctx)
96
- profile = kwargs.pop("profile")
97
- config_file = kwargs.pop("config_file")
98
- creds_file = kwargs.pop("credentials_file")
97
+ profile = kwargs.pop("profile") or ctx.meta.get("profile")
98
+ config_file = kwargs.pop("config_file") or ctx.meta.get("config_file")
99
+ creds_file = kwargs.pop("credentials_file") or ctx.meta.get("creds_file")
100
+
101
+ # Store in context for subcommands to inherit
102
+ ctx.meta["profile"] = profile
103
+ ctx.meta["config_file"] = config_file
104
+ ctx.meta["creds_file"] = creds_file
105
+
99
106
  opts.load_config_file(path=config_file, profile=profile)
100
107
  opts.load_creds_file(path=creds_file, profile=profile)
101
108
  kwargs["opts"] = opts
@@ -330,3 +337,40 @@ def initialise_api(f):
330
337
  return ctx.invoke(f, *args, **kwargs)
331
338
 
332
339
  return wrapper
340
+
341
+
342
+ def initialise_mcp(f):
343
+ @click.option(
344
+ "-a",
345
+ "--all-tools",
346
+ default=False,
347
+ is_flag=True,
348
+ help="Show all tools",
349
+ )
350
+ @click.option(
351
+ "-d",
352
+ "--allow-destructive-tools",
353
+ default=False,
354
+ is_flag=True,
355
+ help="Allow destructive tools to be used",
356
+ )
357
+ @click.pass_context
358
+ @functools.wraps(f)
359
+ def wrapper(ctx, *args, **kwargs):
360
+ opts = kwargs.get("opts")
361
+
362
+ all_tools = kwargs.pop("all_tools")
363
+ allow_destructive_tools = kwargs.pop("allow_destructive_tools")
364
+
365
+ mcp_server = server.DynamicMCPServer(
366
+ api_config=opts.api_config,
367
+ debug_mode=opts.debug,
368
+ allow_destructive_tools=allow_destructive_tools,
369
+ allowed_tool_groups=opts.mcp_allowed_tool_groups,
370
+ allowed_tools=opts.mcp_allowed_tools,
371
+ force_all_tools=all_tools,
372
+ )
373
+ kwargs["mcp_server"] = mcp_server
374
+ return ctx.invoke(f, *args, **kwargs)
375
+
376
+ return wrapper