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

51 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-11 07:10 +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 

33from typing import Optional 

34 

35# Third-Party 

36import typer 

37from typing_extensions import Annotated 

38 

39# First-Party 

40from mcpgateway.config import settings 

41 

42logger = logging.getLogger(__name__) 

43 

44# --------------------------------------------------------------------------- 

45# Configuration defaults 

46# --------------------------------------------------------------------------- 

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

48DEFAULT_AUTHOR_NAME = "<changeme>" 

49DEFAULT_AUTHOR_EMAIL = "<changeme>" 

50DEFAULT_PROJECT_DIR = Path("./.") 

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

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

53DEFAULT_IMAGE_BUILDER = "docker" 

54DEFAULT_BUILD_CONTEXT = "." 

55DEFAULT_CONTAINERFILE_PATH = Path("docker/Dockerfile") 

56DEFAULT_VCS_REF = "main" 

57DEFAULT_INSTALLER = "uv pip install" 

58 

59# --------------------------------------------------------------------------- 

60# CLI (overridable via environment variables) 

61# --------------------------------------------------------------------------- 

62 

63markup_mode = settings.plugins_cli_markup_mode or typer.core.DEFAULT_MARKUP_MODE 

64app = typer.Typer( 

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

66 add_completion=settings.plugins_cli_completion, 

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

68) 

69 

70# --------------------------------------------------------------------------- 

71# Utility functions 

72# --------------------------------------------------------------------------- 

73 

74 

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

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

77 

78 Args: 

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

80 

81 Returns: 

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

83 """ 

84 return shutil.which(command_name) is not None 

85 

86 

87def git_user_name() -> str: 

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

89 

90 Returns: 

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

92 

93 Examples: 

94 >>> user_name = git_user_name() 

95 >>> isinstance(user_name, str) 

96 True 

97 """ 

98 try: 

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

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

101 except Exception: 

102 return DEFAULT_AUTHOR_NAME 

103 

104 

105def git_user_email() -> str: 

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

107 

108 Returns: 

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

110 

111 Examples: 

112 >>> user_name = git_user_email() 

113 >>> isinstance(user_name, str) 

114 True 

115 """ 

116 try: 

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

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

119 except Exception: 

120 return DEFAULT_AUTHOR_EMAIL 

121 

122 

123# --------------------------------------------------------------------------- 

124# Commands 

125# --------------------------------------------------------------------------- 

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

127def bootstrap( 

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

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

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 answers_file: Optional[Annotated[typer.FileText, typer.Option("--answers_file", "-a", help="The answers file to be used for bootstrapping.")]] = None, 

132 defaults: Annotated[bool, typer.Option("--defaults", help="Bootstrap with defaults.")] = False, 

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

134) -> None: 

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

136 

137 Args: 

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

139 template_url: The URL to the plugins copier template. 

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

141 answers_file: The copier answers file that can be used to skip interactive mode. 

142 defaults: Bootstrap with defaults. 

143 dry_run: Run but do not make any changes. 

144 

145 Raises: 

146 Exit: If copier is not installed. 

147 """ 

148 try: 

149 # Third-Party 

150 from copier import run_copy # pylint: disable=import-outside-toplevel 

151 except ImportError: 

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

153 raise typer.Exit(1) 

154 

155 try: 

156 if command_exists("git"): 

157 run_copy( 

158 src_path=template_url, 

159 dst_path=destination, 

160 answers_file=answers_file, 

161 defaults=defaults, 

162 vcs_ref=vcs_ref, 

163 data={"default_author_name": git_user_name(), "default_author_email": git_user_email()}, 

164 pretend=dry_run, 

165 ) 

166 else: 

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

168 except Exception: 

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

170 

171 

172@app.callback() 

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

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

175 

176 

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

178# def install( 

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

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

181# ): 

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

183# data = yaml.safe_load(install_manifest) 

184# manifest = InstallManifest.model_validate(data) 

185# for pkg in manifest.packages: 

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

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

188# cmd = installer.split(" ") 

189# if pkg.extras: 

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

191# else: 

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

193# subprocess.run(cmd) 

194 

195 

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

197# def package( 

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

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

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

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

202# ): 

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

204# cmd = builder.split(" ") 

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

206# subprocess.run(cmd) 

207 

208 

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

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

211 

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

213 all other arguments to Uvicorn with sensible defaults injected. 

214 

215 Environment Variables: 

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

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

218 Valid options: 

219 rich: use rich markup 

220 markdown: allow markdown in help strings 

221 disabled: disable markup 

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

223 """ 

224 app() 

225 

226 

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

228 main()