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
« 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
7mcpplugins CLI ─ command line tools for authoring and packaging plugins
8This module is exposed as a **console-script** via:
10 [project.scripts]
11 mcpplugins = "mcpgateway.plugins.tools.cli:main"
13so that a user can simply type `mcpplugins ...` to use the CLI.
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
21Typical usage
22─────────────
23```console
24$ mcpplugins --help
25```
26"""
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
35# Third-Party
36import typer
37from typing_extensions import Annotated
39# First-Party
40from mcpgateway.config import settings
42logger = logging.getLogger(__name__)
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"
59# ---------------------------------------------------------------------------
60# CLI (overridable via environment variables)
61# ---------------------------------------------------------------------------
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)
70# ---------------------------------------------------------------------------
71# Utility functions
72# ---------------------------------------------------------------------------
75def command_exists(command_name: str) -> bool:
76 """Check if a given command-line utility exists and is executable.
78 Args:
79 command_name: The name of the command to check (e.g., "ls", "git").
81 Returns:
82 True if the command exists and is executable, False otherwise.
83 """
84 return shutil.which(command_name) is not None
87def git_user_name() -> str:
88 """Return the current git user name from the environment.
90 Returns:
91 The git user name configured in the user's environment.
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
105def git_user_email() -> str:
106 """Return the current git user email from the environment.
108 Returns:
109 The git user email configured in the user's environment.
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
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.
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.
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)
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.")
172@app.callback()
173def callback() -> None: # pragma: no cover
174 """This function exists to force 'bootstrap' to be a subcommand."""
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)
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)
209def main() -> None: # noqa: D401 - imperative mood is fine here
210 """Entry point for the *mcpplugins* console script.
212 Processes command line arguments, handles version requests, and forwards
213 all other arguments to Uvicorn with sensible defaults injected.
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()
227if __name__ == "__main__": # pragma: no cover - executed only when run directly
228 main()