Coverage for mcpgateway / plugins / tools / cli.py: 100%

55 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 03:05 +0000

1# -*- coding: utf-8 -*- 

2"""Location: ./mcpgateway/plugins/tools/cli.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5Authors: Fred Araujo 

6 

7mcpplugins CLI ─ command line tools for authoring and packaging plugins 

8This module is exposed as a **console-script** via: 

9 

10 [project.scripts] 

11 mcpplugins = "mcpgateway.plugins.tools.cli:main" 

12 

13so that a user can simply type `mcpplugins ...` to use the CLI. 

14 

15Features 

16───────── 

17* bootstrap: Creates a new plugin project from template │ 

18* install: Installs plugins into a Python environment │ 

19* package: Builds an MCP server to serve plugins as tools 

20 

21Typical usage 

22───────────── 

23```console 

24$ mcpplugins --help 

25``` 

26""" 

27 

28# Standard 

29import logging 

30from pathlib import Path 

31import shutil 

32import subprocess # nosec B404 # Safe: Used only for git commands with hardcoded args 

33 

34# Third-Party 

35import typer 

36from typing_extensions import Annotated 

37 

38# First-Party 

39from mcpgateway.plugins.framework.settings import settings 

40 

41logger = logging.getLogger(__name__) 

42 

43# --------------------------------------------------------------------------- 

44# Configuration defaults 

45# --------------------------------------------------------------------------- 

46DEFAULT_TEMPLATE_URL = "https://github.com/IBM/mcp-context-forge.git" 

47DEFAULT_AUTHOR_NAME = "<changeme>" 

48DEFAULT_AUTHOR_EMAIL = "<changeme>" 

49DEFAULT_PROJECT_DIR = Path("./.") 

50DEFAULT_INSTALL_MANIFEST = Path("plugins/install.yaml") 

51DEFAULT_IMAGE_TAG = "contextforge-plugin:latest" # TBD: add plugin name and version 

52DEFAULT_IMAGE_BUILDER = "docker" 

53DEFAULT_BUILD_CONTEXT = "." 

54DEFAULT_CONTAINERFILE_PATH = Path("docker/Dockerfile") 

55DEFAULT_VCS_REF = "main" 

56DEFAULT_INSTALLER = "uv pip install" 

57 

58# --------------------------------------------------------------------------- 

59# CLI (overridable via environment variables) 

60# --------------------------------------------------------------------------- 

61 

62markup_mode = settings.cli_markup_mode or typer.core.DEFAULT_MARKUP_MODE 

63app = typer.Typer( 

64 help="Command line tools for authoring and packaging plugins.", 

65 add_completion=settings.cli_completion, 

66 rich_markup_mode=None if markup_mode == "disabled" else markup_mode, 

67) 

68 

69# --------------------------------------------------------------------------- 

70# Utility functions 

71# --------------------------------------------------------------------------- 

72 

73 

74def command_exists(command_name: str) -> bool: 

75 """Check if a given command-line utility exists and is executable. 

76 

77 Args: 

78 command_name: The name of the command to check (e.g., "ls", "git"). 

79 

80 Returns: 

81 True if the command exists and is executable, False otherwise. 

82 """ 

83 return shutil.which(command_name) is not None 

84 

85 

86def git_user_name() -> str: 

87 """Return the current git user name from the environment. 

88 

89 Returns: 

90 The git user name configured in the user's environment. 

91 

92 Examples: 

93 >>> user_name = git_user_name() 

94 >>> isinstance(user_name, str) 

95 True 

96 """ 

97 try: 

98 res = subprocess.run(["git", "config", "user.name"], stdout=subprocess.PIPE, check=False) # nosec B607 B603 # Safe: hardcoded git command 

99 return res.stdout.strip().decode() if not res.returncode else DEFAULT_AUTHOR_NAME 

100 except Exception: 

101 return DEFAULT_AUTHOR_NAME 

102 

103 

104def git_user_email() -> str: 

105 """Return the current git user email from the environment. 

106 

107 Returns: 

108 The git user email configured in the user's environment. 

109 

110 Examples: 

111 >>> user_name = git_user_email() 

112 >>> isinstance(user_name, str) 

113 True 

114 """ 

115 try: 

116 res = subprocess.run(["git", "config", "user.email"], stdout=subprocess.PIPE, check=False) # nosec B607 B603 # Safe: hardcoded git command 

117 return res.stdout.strip().decode() if not res.returncode else DEFAULT_AUTHOR_EMAIL 

118 except Exception: 

119 return DEFAULT_AUTHOR_EMAIL 

120 

121 

122# --------------------------------------------------------------------------- 

123# Commands 

124# --------------------------------------------------------------------------- 

125@app.command(help="Creates a new plugin project from template.") 

126def bootstrap( 

127 destination: Annotated[Path, typer.Option("--destination", "-d", help="The directory in which to bootstrap the plugin project.")] = DEFAULT_PROJECT_DIR, 

128 template_url: Annotated[str, typer.Option("--template_url", "-u", help="The URL to the plugins cookiecutter template.")] = DEFAULT_TEMPLATE_URL, 

129 template_type: Annotated[str, typer.Option("--template_type", "-t", help="Plugin template type: native or external.")] = "native", 

130 vcs_ref: Annotated[str, typer.Option("--vcs_ref", "-r", help="The version control system tag/branch/commit to use for the template.")] = DEFAULT_VCS_REF, 

131 no_input: Annotated[bool, typer.Option("--no_input", help="Use defaults without prompting.")] = False, 

132 dry_run: Annotated[bool, typer.Option("--dry_run", help="Run but do not make any changes.")] = False, 

133) -> None: 

134 """Boostrap a new plugin project from a template. 

135 

136 Args: 

137 destination: The directory in which to bootstrap the plugin project. 

138 template_url: The URL to the plugins cookiecutter template. 

139 template_type: Plugin template type (native or external). 

140 vcs_ref: The version control system tag/branch/commit to use for the template. 

141 no_input: Use defaults without prompting. 

142 dry_run: Run but do not make any changes. 

143 

144 Raises: 

145 Exit: If cookiecutter is not installed. 

146 """ 

147 try: 

148 # Third-Party 

149 from cookiecutter.main import cookiecutter # pylint: disable=import-outside-toplevel 

150 except ImportError: 

151 logger.error("cookiecutter is not installed. Install with: pip install mcp-contextforge-gateway[templating]") 

152 raise typer.Exit(1) 

153 

154 if dry_run: 

155 logger.info("Dry run: would create plugin project at %s from template %s (type=%s)", destination, template_url, template_type) 

156 return 

157 

158 try: 

159 if command_exists("git"): 

160 output_dir = str(destination.parent) if destination.parent != destination else "." 

161 extra_context = { 

162 "plugin_slug": destination.name, 

163 "author": git_user_name(), 

164 "email": git_user_email(), 

165 } 

166 cookiecutter( 

167 template=template_url, 

168 checkout=vcs_ref, 

169 directory=f"plugin_templates/{template_type}", 

170 output_dir=output_dir, 

171 no_input=no_input, 

172 extra_context=extra_context, 

173 ) 

174 else: 

175 logger.warning("A git client was not found in the environment to copy remote template.") 

176 except Exception: 

177 logger.exception("An error was caught while copying template.") 

178 

179 

180@app.callback() 

181def callback() -> None: # pragma: no cover 

182 """This function exists to force 'bootstrap' to be a subcommand.""" 

183 

184 

185# @app.command(help="Installs plugins into a Python environment.") 

186# def install( 

187# install_manifest: Annotated[typer.FileText, typer.Option("--install_manifest", "-i", help="The install manifest describing which plugins to install.")] = DEFAULT_INSTALL_MANIFEST, 

188# installer: Annotated[str, typer.Option("--installer", "-c", help="The install command to install plugins.")] = DEFAULT_INSTALLER, 

189# ): 

190# typer.echo(f"Installing plugin packages from {install_manifest.name}") 

191# data = yaml.safe_load(install_manifest) 

192# manifest = InstallManifest.model_validate(data) 

193# for pkg in manifest.packages: 

194# typer.echo(f"Installing plugin package {pkg.package} from {pkg.repository}") 

195# repository = os.path.expandvars(pkg.repository) 

196# cmd = installer.split(" ") 

197# if pkg.extras: 

198# cmd.append(f"{pkg.package}[{','.join(pkg.extras)}]@{repository}") 

199# else: 

200# cmd.append(f"{pkg.package}@{repository}") 

201# subprocess.run(cmd) 

202 

203 

204# @app.command(help="Builds an MCP server to serve plugins as tools.") 

205# def package( 

206# image_tag: Annotated[str, typer.Option("--image_tag", "-t", help="The container image tag to generated container.")] = DEFAULT_IMAGE_TAG, 

207# containerfile: Annotated[Path, typer.Option("--containerfile", "-c", help="The Dockerfile used to build the container.")] = DEFAULT_CONTAINERFILE_PATH, 

208# builder: Annotated[str, typer.Option("--builder", "-b", help="The container builder, compatible with docker build.")] = DEFAULT_IMAGE_BUILDER, 

209# build_context: Annotated[Path, typer.Option("--build_context", "-p", help="The container builder context, specified as a path.")] = DEFAULT_BUILD_CONTEXT, 

210# ): 

211# typer.echo("Building MCP server image") 

212# cmd = builder.split(" ") 

213# cmd.extend(["-f", containerfile, "-t", image_tag, build_context]) 

214# subprocess.run(cmd) 

215 

216 

217def main() -> None: # noqa: D401 - imperative mood is fine here 

218 """Entry point for the *mcpplugins* console script. 

219 

220 Processes command line arguments, handles version requests, and forwards 

221 all other arguments to Uvicorn with sensible defaults injected. 

222 

223 Environment Variables: 

224 PLUGINS_CLI_COMPLETION: Enable auto-completion for plugins CLI (default: false) 

225 PLUGINS_CLI_MARKUP_MODE: Set markup mode for plugins CLI (default: rich) 

226 Valid options: 

227 rich: use rich markup 

228 markdown: allow markdown in help strings 

229 disabled: disable markup 

230 If unset (commented out), uses "rich" if rich is detected, otherwise disables it. 

231 """ 

232 app() 

233 

234 

235if __name__ == "__main__": # pragma: no cover - executed only when run directly 

236 main()