Files
Vincenzo Reina d344233c50 feat(python): add create-mcp-use CLI with rich UI and dependency install (#1097)
* Add uvx create-mcp-use command

* Update docs

* Add docs

* feat(python/cli): improve create-mcp-use with rich UI, dependency install, and telemetry

- Use rich for colored output, spinner, and panel for get-started instructions
- Add ASCII logo matching the TypeScript CLI
- Prompt user to install dependencies after scaffolding (detects uv/pip)
- Add --install and --no-install flags for non-interactive use
- Capture install output to keep terminal clean
- Add CreateMCPUseEvent telemetry to track CLI usage
- Fix starter template pyproject.toml hatch build config for installability

* feat(python/server): auto-retry next available port when default is in use

Add port availability checking before starting uvicorn. When the
requested port is occupied, the server automatically tries the next
port (up to 10 retries) instead of crashing.

Refs #1161

* fix(python/cli): fix linting errors in create-mcp-use CLI

* Revert "feat(python/server): auto-retry next available port when default is in use"

This reverts commit baf3d3233e.

* fix(python/cli): address PR review feedback

- Fix ruff format issues in cli.py
- Fix "Cli" → "CLI" in docs title
- Fix "a MCP" → "an MCP" grammar in quickstart

* docs(python): regenerate API docs for CLI module

* docs: update

---------

Co-authored-by: pietrozullo <pietro.zullo@gmail.com>
2026-03-06 12:25:19 +01:00

220 lines
8.3 KiB
Python

"""CLI tool to scaffold a new MCP server project using mcp-use."""
import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm
from rich.status import Status
console = Console()
TEMPLATES_DIR = Path(__file__).parent
TEMPLATE_SUFFIX = ".tmpl"
LOGO = r"""
[bold white] ███╗ ███╗ ██████╗ ██████╗ ██╗ ██╗ ███████╗ ███████╗[/bold white]
[bold white] ████╗ ████║ ██╔════╝ ██╔══██╗ ██║ ██║ ██╔════╝ ██╔════╝[/bold white]
[bold white] ██╔████╔██║ ██║ ██████╔╝ ━━━━ ██║ ██║ ███████╗ █████╗ [/bold white]
[bold white] ██║╚██╔╝██║ ██║ ██╔═══╝ ━━━━ ██║ ██║ ╚════██║ ██╔══╝ [/bold white]
[bold white] ██║ ╚═╝ ██║ ╚██████╗ ██║ ╚██████╔╝ ███████║ ███████╗[/bold white]
[bold white] ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚══════╝[/bold white]
"""
def get_available_templates() -> list[str]:
"""Returns the list of available templates."""
return [
d.name for d in TEMPLATES_DIR.iterdir() if d.is_dir() and d.name != "__pycache__" and not d.name.startswith(".")
]
def validate_project_name(name: str) -> str:
"""Check if the inserted name is a valid project name."""
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
console.print(
f"[red]Error:[/red] Invalid project name '{name}'. Use only letters, numbers, hyphens, underscores."
)
sys.exit(1)
if name in {"src", "dist", ".git", ".env", "node_modules"}:
console.print(f"[red]Error:[/red] '{name}' is a reserved name.")
sys.exit(1)
return name
def render_template(content: str, context: dict[str, str]) -> str:
"""Replace {{KEY}} placeholders with context values."""
for key, value in context.items():
content = content.replace(f"{{{{{key}}}}}", value)
return content
def copy_template(template_dir: Path, target_dir: Path, context: dict[str, str]) -> None:
"""Copy the template to the target folder after replacing placeholders."""
for src_path in template_dir.rglob("*"):
if src_path.is_dir() or "__pycache__" in src_path.parts:
continue
rel_path = src_path.relative_to(template_dir)
# Strip .tmpl suffix and handle gitignore
dest_name = rel_path.name
if dest_name.endswith(TEMPLATE_SUFFIX):
dest_name = dest_name[: -len(TEMPLATE_SUFFIX)]
if dest_name == "gitignore":
dest_name = ".gitignore"
dest_path = target_dir / rel_path.parent / dest_name
dest_path.parent.mkdir(parents=True, exist_ok=True)
content = src_path.read_text(encoding="utf-8")
rendered = render_template(content, context)
dest_path.write_text(rendered, encoding="utf-8")
def detect_installer() -> tuple[str, list[str]]:
"""Detect the best available package installer. Returns (name, install_command)."""
if shutil.which("uv"):
return ("uv", ["uv", "pip", "install", "."])
if shutil.which("pip"):
return ("pip", ["pip", "install", "."])
return ("", [])
def run_install(name: str, cmd: list[str], target_dir: Path) -> bool:
"""Run the installer, capturing output. Returns True on success."""
with Status(f" Installing dependencies with [bold]{name}[/bold]...", console=console):
result = subprocess.run(cmd, cwd=target_dir, capture_output=True, text=True)
if result.returncode == 0:
console.print(" [green]✓[/green] Dependencies installed.")
return True
else:
console.print(" [red]✗[/red] Installation failed.")
if result.stderr:
console.print(f"\n{result.stderr.strip()}")
console.print(f"\n Run manually:\n cd {target_dir.name}\n {' '.join(cmd)}")
return False
def install_dependencies(target_dir: Path) -> bool:
"""Prompt user to install dependencies and run the installer. Returns True if installed."""
name, cmd = detect_installer()
if not name:
console.print("[yellow]No package installer found (uv or pip).[/yellow]")
console.print(f" Install dependencies manually:\n cd {target_dir.name}\n pip install .")
return False
try:
if Confirm.ask(f"\n Install dependencies with [bold]{name}[/bold]?", default=True, console=console):
return run_install(name, cmd, target_dir)
except (EOFError, KeyboardInterrupt):
console.print()
return False
def main():
parser = argparse.ArgumentParser(
prog="create-mcp-use", description="Scaffold a new MCP server project using mcp-use"
)
# This argument is optional (for example in case of --list-templates)
parser.add_argument("project_name", nargs="?", help="Name of the project to create")
parser.add_argument(
"-t",
"--template",
default="starter",
choices=get_available_templates(),
help="Template to use (default: starter)",
)
parser.add_argument("--list-templates", action="store_true", help="List available templates")
parser.add_argument("--install", action="store_true", default=None, help="Install dependencies automatically")
parser.add_argument("--no-install", action="store_true", help="Skip installing dependencies")
args = parser.parse_args()
if not args.list_templates and not args.project_name:
parser.error("project_name is required")
if args.list_templates:
console.print("\n[bold]Available templates:[/bold]")
for t in get_available_templates():
console.print(f" • {t}")
sys.exit(0)
project_name = validate_project_name(args.project_name)
target_dir = Path.cwd() / project_name
if target_dir.exists():
console.print(f"[red]Error:[/red] Directory '{project_name}' already exists.")
sys.exit(1)
template_dir = TEMPLATES_DIR / args.template
if not template_dir.exists():
console.print(f"[red]Error:[/red] Template '{args.template}' not found.")
sys.exit(1)
# Derive a Python safe module name for use in imports
module_name = project_name.replace("-", "_")
context = {
"PROJECT_NAME": project_name,
"MODULE_NAME": module_name,
}
console.print(LOGO)
console.print(
f" Creating MCP server [bold cyan]{project_name}[/bold cyan] with template [bold]{args.template}[/bold]...\n"
)
copy_template(template_dir, target_dir, context)
console.print(f" [green]✓[/green] Project created at [bold]./{project_name}/[/bold]")
# Handle dependency installation
installed = False
installer_name = None
if args.no_install:
pass
elif args.install:
installer_name, cmd = detect_installer()
if installer_name:
installed = run_install(installer_name, cmd, target_dir)
else:
installer_name, _ = detect_installer()
installed = install_dependencies(target_dir)
# Track CLI usage
try:
from mcp_use.telemetry import Telemetry
from mcp_use.telemetry.events import CreateMCPUseEvent
telemetry = Telemetry()
telemetry.capture(
CreateMCPUseEvent(
project_name=project_name,
template=args.template,
install_deps=not args.no_install,
deps_installed=installed,
installer=installer_name,
)
)
telemetry.flush()
except Exception:
pass
# Build the "get started" instructions
steps = f"cd {project_name}\n"
if not installed:
steps += "pip install .\n"
steps += "python server.py"
console.print()
console.print(Panel(steps, title="[bold]Get started[/bold]", border_style="cyan", padding=(1, 2)))
console.print(
" Docs: [link=https://mcp-use.com/docs/python/server]https://mcp-use.com/docs/python/server[/link]\n"
)