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
« 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
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
34# Third-Party
35import typer
36from typing_extensions import Annotated
38# First-Party
39from mcpgateway.plugins.framework.settings import settings
41logger = logging.getLogger(__name__)
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"
58# ---------------------------------------------------------------------------
59# CLI (overridable via environment variables)
60# ---------------------------------------------------------------------------
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)
69# ---------------------------------------------------------------------------
70# Utility functions
71# ---------------------------------------------------------------------------
74def command_exists(command_name: str) -> bool:
75 """Check if a given command-line utility exists and is executable.
77 Args:
78 command_name: The name of the command to check (e.g., "ls", "git").
80 Returns:
81 True if the command exists and is executable, False otherwise.
82 """
83 return shutil.which(command_name) is not None
86def git_user_name() -> str:
87 """Return the current git user name from the environment.
89 Returns:
90 The git user name configured in the user's environment.
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
104def git_user_email() -> str:
105 """Return the current git user email from the environment.
107 Returns:
108 The git user email configured in the user's environment.
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
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.
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.
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)
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
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.")
180@app.callback()
181def callback() -> None: # pragma: no cover
182 """This function exists to force 'bootstrap' to be a subcommand."""
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)
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)
217def main() -> None: # noqa: D401 - imperative mood is fine here
218 """Entry point for the *mcpplugins* console script.
220 Processes command line arguments, handles version requests, and forwards
221 all other arguments to Uvicorn with sensible defaults injected.
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()
235if __name__ == "__main__": # pragma: no cover - executed only when run directly
236 main()