From e9fa9fcf0d11a5743bfec20459f0f2d38eb8869b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 6 Jul 2025 21:10:45 -0400 Subject: [PATCH] Add --copy flag for fastmcp version --- pyproject.toml | 1 + src/fastmcp/cli/cli.py | 23 +++++++++++++++-- src/fastmcp/cli/install/mcp_config.py | 15 +++-------- tests/cli/test_cli.py | 37 +++++++++++++++++++++++++++ uv.lock | 2 ++ 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ad398f6c..3e3dea61c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "cyclopts>=3.0.0", "authlib>=1.5.2", "pydantic[email]>=2.11.7", + "pyperclip>=1.9.0", ] requires-python = ">=3.10" readme = "README.md" diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index f2dcec314..3fd379211 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Annotated, Literal import cyclopts +import pyperclip from pydantic import TypeAdapter from rich.console import Console from rich.table import Table @@ -85,7 +86,17 @@ def _build_uv_command( @app.command -def version(): +def version( + *, + copy: Annotated[ + bool, + cyclopts.Parameter( + "--copy", + help="Copy version information to clipboard", + negative=False, + ), + ] = False, +): """Display version information and platform details.""" info = { "FastMCP version": fastmcp.__version__, @@ -100,7 +111,15 @@ def version(): g.add_column(style="cyan", justify="right") for k, v in info.items(): g.add_row(k + ":", str(v).replace("\n", " ")) - console.print(g) + + if copy: + # Use Rich's capture to get text representation + with console.capture() as capture: + console.print(g) + pyperclip.copy(capture.get()) + console.print("[green]✓[/green] Version information copied to clipboard") + else: + console.print(g) sys.exit(0) diff --git a/src/fastmcp/cli/install/mcp_config.py b/src/fastmcp/cli/install/mcp_config.py index f96eed10e..29eb898c5 100644 --- a/src/fastmcp/cli/install/mcp_config.py +++ b/src/fastmcp/cli/install/mcp_config.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Annotated import cyclopts +import pyperclip from rich import print from fastmcp.utilities.logging import get_logger @@ -79,18 +80,8 @@ def install_mcp_config( # Handle output if copy: - try: - import pyperclip - - pyperclip.copy(json_output) - print( - f"[green]MCP configuration for '{name}' copied to clipboard[/green]" - ) - except ImportError: - print( - "[red]The --copy flag requires pyperclip. Please install pyperclip and try again: pip install pyperclip[/red]" - ) - return False + pyperclip.copy(json_output) + print(f"[green]MCP configuration for '{name}' copied to clipboard[/green]") else: # Print to stdout (for piping) print(json_output) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 2aaab1e3d..9d96feaa7 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -108,6 +108,43 @@ class TestVersionCommand: mock_print.assert_called_once() mock_exit.assert_called_once_with(0) + def test_version_command_parsing(self): + """Test that the version command parses arguments correctly.""" + command, bound, _ = app.parse_args(["version"]) + assert command.__name__ == "version" + # Default arguments aren't included in bound.arguments + assert bound.arguments == {} + + def test_version_command_with_copy_flag(self): + """Test that the version command parses --copy flag correctly.""" + command, bound, _ = app.parse_args(["version", "--copy"]) + assert command.__name__ == "version" + assert bound.arguments == {"copy": True} + + @patch("fastmcp.cli.cli.sys.exit") + @patch("fastmcp.cli.cli.pyperclip.copy") + @patch("fastmcp.cli.cli.console") + def test_version_command_copy_functionality( + self, mock_console, mock_pyperclip_copy, mock_exit + ): + """Test that the version command copies to clipboard when --copy is used.""" + # Mock console.capture + mock_capture = Mock() + mock_capture.get.return_value = "FastMCP version: 1.0.0\nMCP version: 1.10.0" + mock_console.capture.return_value.__enter__.return_value = mock_capture + mock_console.capture.return_value.__exit__.return_value = None + + command, bound, _ = app.parse_args(["version", "--copy"]) + command(**bound.arguments) + + mock_pyperclip_copy.assert_called_once_with( + "FastMCP version: 1.0.0\nMCP version: 1.10.0" + ) + mock_console.print.assert_called_with( + "[green]✓[/green] Version information copied to clipboard" + ) + mock_exit.assert_called_once_with(0) + class TestDevCommand: """Test the dev command.""" diff --git a/uv.lock b/uv.lock index 9fc747b15..1faaaded1 100644 --- a/uv.lock +++ b/uv.lock @@ -502,6 +502,7 @@ dependencies = [ { name = "mcp" }, { name = "openapi-pydantic" }, { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, { name = "python-dotenv" }, { name = "rich" }, ] @@ -544,6 +545,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.10.0" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, + { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "websockets", marker = "extra == 'websockets'", specifier = ">=15.0.1" },