Merge main into concurrency-fix-1054

Resolved conflicts in client.py by keeping our improved RuntimeError
checks instead of the assert statements from main.
This commit is contained in:
Jeremiah Lowin 2025-07-06 11:14:53 -04:00
commit a975db6e6c
27 changed files with 1176 additions and 233 deletions

View file

@ -18,7 +18,7 @@ from typer import Context, Exit
import fastmcp
from fastmcp.cli import run as run_module
from fastmcp.cli.install.install import install
from fastmcp.cli.install import install_app
from fastmcp.server.server import FastMCP
from fastmcp.utilities.inspect import FastMCPInfo, inspect_fastmcp
from fastmcp.utilities.logging import get_logger
@ -323,8 +323,8 @@ def run(
sys.exit(1)
# Add install command directly
app.command()(install)
# Add install subcommands
app.add_typer(install_app)
@app.command()

View file

@ -1,5 +1,27 @@
"""Install module for FastMCP CLI."""
"""Install subcommands for FastMCP CLI."""
from .install import install
import typer
__all__ = ["install"]
from .claude_code import claude_code_command
from .claude_desktop import claude_desktop_command
from .cursor import cursor_command
from .mcp_config import mcp_config_command
# Create a typer app for install subcommands
install_app = typer.Typer(
name="install",
help="Install MCP servers in various clients and formats",
no_args_is_help=True,
)
# Register each command from its respective module
install_app.command("claude-code", help="Install a MCP server in Claude Code")(
claude_code_command
)
install_app.command("claude-desktop", help="Install a MCP server in Claude Desktop")(
claude_desktop_command
)
install_app.command("cursor", help="Install a MCP server in Cursor")(cursor_command)
install_app.command(
"mcp-json", help="Generate MCP JSON configuration for manual installation"
)(mcp_config_command)

View file

@ -3,12 +3,17 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from typing import Annotated
import typer
from rich import print
from fastmcp.utilities.logging import get_logger
from .shared import process_common_args
logger = get_logger(__name__)
@ -116,3 +121,73 @@ def install_claude_code(
except Exception as e:
print(f"[red]Failed to install '[bold]{name}[/bold]' in Claude Code: {e}[/red]")
return False
def claude_code_command(
server_spec: Annotated[
str, typer.Argument(help="Python file to run, optionally with :object suffix")
],
server_name: Annotated[
str | None,
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to server's name attribute or file name)",
),
] = None,
with_editable: Annotated[
Path | None,
typer.Option(
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
with_packages: Annotated[
list[str],
typer.Option(
"--with", help="Additional packages to install, in PEP 508 format"
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var", "-v", help="Environment variables in KEY=VALUE format"
),
] = [],
env_file: Annotated[
Path | None,
typer.Option(
"--env-file",
"-f",
help="Load environment variables from a .env file",
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Install a MCP server in Claude Code."""
file, server_object, name, packages, env_dict = process_common_args(
server_spec, server_name, with_packages, env_vars, env_file
)
success = install_claude_code(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=packages,
env_vars=env_dict,
)
if success:
print(
f"[green bold]Successfully installed '[bold]{name}[/bold]' in Claude Code[/green bold]"
)
else:
sys.exit(1)

View file

@ -5,12 +5,16 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Annotated
import typer
from rich import print
from fastmcp.mcp_config import StdioMCPServer, update_config_file
from fastmcp.utilities.logging import get_logger
from .shared import process_common_args
logger = get_logger(__name__)
@ -119,3 +123,73 @@ def install_claude_desktop(
f"[red]Failed to install '[bold]{name}[/bold]' in Claude Desktop: {e}[/red]"
)
return False
def claude_desktop_command(
server_spec: Annotated[
str, typer.Argument(help="Python file to run, optionally with :object suffix")
],
server_name: Annotated[
str | None,
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to server's name attribute or file name)",
),
] = None,
with_editable: Annotated[
Path | None,
typer.Option(
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
with_packages: Annotated[
list[str],
typer.Option(
"--with", help="Additional packages to install, in PEP 508 format"
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var", "-v", help="Environment variables in KEY=VALUE format"
),
] = [],
env_file: Annotated[
Path | None,
typer.Option(
"--env-file",
"-f",
help="Load environment variables from a .env file",
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Install a MCP server in Claude Desktop."""
file, server_object, name, packages, env_dict = process_common_args(
server_spec, server_name, with_packages, env_vars, env_file
)
success = install_claude_desktop(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=packages,
env_vars=env_dict,
)
if success:
print(
f"[green bold]Successfully installed '[bold]{name}[/bold]' in Claude Desktop[/green bold]"
)
else:
sys.exit(1)

View file

@ -6,12 +6,16 @@ import base64
import subprocess
import sys
from pathlib import Path
from typing import Annotated
import typer
from rich import print
from fastmcp.mcp_config import StdioMCPServer
from fastmcp.utilities.logging import get_logger
from .shared import process_common_args
logger = get_logger(__name__)
@ -133,3 +137,70 @@ def install_cursor(
except Exception as e:
print(f"[red]Failed to generate Cursor deeplink: {e}[/red]")
return False
def cursor_command(
server_spec: Annotated[
str, typer.Argument(help="Python file to run, optionally with :object suffix")
],
server_name: Annotated[
str | None,
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to server's name attribute or file name)",
),
] = None,
with_editable: Annotated[
Path | None,
typer.Option(
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
with_packages: Annotated[
list[str],
typer.Option(
"--with", help="Additional packages to install, in PEP 508 format"
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var", "-v", help="Environment variables in KEY=VALUE format"
),
] = [],
env_file: Annotated[
Path | None,
typer.Option(
"--env-file",
"-f",
help="Load environment variables from a .env file",
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Install a MCP server in Cursor."""
file, server_object, name, packages, env_dict = process_common_args(
server_spec, server_name, with_packages, env_vars, env_file
)
success = install_cursor(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=packages,
env_vars=env_dict,
)
# Cursor handles its own messaging, no generic success message needed
if not success:
sys.exit(1)

View file

@ -1,198 +0,0 @@
"""Main install logic for FastMCP CLI."""
from __future__ import annotations
import sys
from enum import Enum
from pathlib import Path
from typing import Annotated
import typer
from dotenv import dotenv_values
from rich import print
from fastmcp.cli.run import import_server, parse_file_path
from fastmcp.utilities.logging import get_logger
from .claude_code import install_claude_code
from .claude_desktop import install_claude_desktop
from .cursor import install_cursor
logger = get_logger(__name__)
class Client(str, Enum):
"""Supported MCP clients."""
CLAUDE_CODE = "claude-code"
CLAUDE_DESKTOP = "claude-desktop"
CURSOR = "cursor"
def install(
client: Annotated[
Client,
typer.Argument(help="MCP client to install the server into"),
],
server_spec: Annotated[
str, typer.Argument(help="Python file to run, optionally with :object suffix")
],
server_name: Annotated[
str | None,
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to server's name attribute or file name)",
),
] = None,
with_editable: Annotated[
Path | None,
typer.Option(
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode. Use this to include local packages that are not available on PyPI.",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
with_packages: Annotated[
list[str],
typer.Option(
"--with",
help="Additional packages to install, in PEP 508 format (e.g. 'httpx>=0.25.2')",
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var",
"-v",
help="Environment variables in KEY=VALUE format",
),
] = [],
env_file: Annotated[
Path | None,
typer.Option(
"--env-file",
"-f",
help="Load environment variables from a .env file",
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Install a MCP server in the specified target application.
Environment variables are preserved once added and only updated if new values
are explicitly provided.
"""
# Parse server spec
file, server_object = parse_file_path(server_spec)
logger.debug(
"Installing server",
extra={
"client": client,
"file": str(file),
"server_name": server_name,
"server_object": server_object,
"with_editable": str(with_editable) if with_editable else None,
"with_packages": with_packages,
},
)
# Try to import server to get its name and dependencies
name = server_name
server = None
if not name:
try:
server = import_server(file, server_object)
name = server.name
except (ImportError, ModuleNotFoundError) as e:
logger.debug(
"Could not import server (likely missing dependencies), using file name",
extra={"error": str(e)},
)
name = file.stem
# Get server dependencies if available
server_dependencies = getattr(server, "dependencies", []) if server else []
if server_dependencies:
with_packages = list(set(with_packages + server_dependencies))
# Process environment variables if provided
env_dict: dict[str, str] | None = None
if env_file or env_vars:
env_dict = {}
# Load from .env file if specified
if env_file:
try:
env_dict |= {
k: v for k, v in dotenv_values(env_file).items() if v is not None
}
except Exception as e:
print(f"[red]❌ Failed to load .env file: {e}[/red]")
sys.exit(1)
# Add command line environment variables
for env_var in env_vars:
key, value = _parse_env_var(env_var)
env_dict[key] = value
# Route to appropriate installer
if client == Client.CLAUDE_CODE:
success = install_claude_code(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=with_packages,
env_vars=env_dict,
)
elif client == Client.CLAUDE_DESKTOP:
success = install_claude_desktop(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=with_packages,
env_vars=env_dict,
)
elif client == Client.CURSOR:
success = install_cursor(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=with_packages,
env_vars=env_dict,
)
else:
print(
f"[red bold]Unknown client: {client!r}[/red bold]. Supported clients: [bold]{Client.CLAUDE_CODE}[/bold], [bold]{Client.CLAUDE_DESKTOP}[/bold], [bold]{Client.CURSOR}[/bold]"
)
raise typer.Exit(1)
if success:
# Only show generic success message for clients that don't have their own messaging
if client != Client.CURSOR:
print(
f"[green bold]Successfully installed '[bold]{name}[/bold]' in {client.value}[/green bold]"
)
else:
sys.exit(1)
def _parse_env_var(env_var: str) -> tuple[str, str]:
"""Parse environment variable string in format KEY=VALUE."""
if "=" not in env_var:
print(
f"[red]❌ Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]"
)
sys.exit(1)
key, value = env_var.split("=", 1)
return key.strip(), value.strip()

View file

@ -0,0 +1,179 @@
"""MCP configuration JSON generation for FastMCP install."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Annotated
import typer
from rich import print
from fastmcp.utilities.logging import get_logger
from .shared import process_common_args
logger = get_logger(__name__)
def install_mcp_config(
file: Path,
server_object: str | None,
name: str,
*,
with_editable: Path | None = None,
with_packages: list[str] | None = None,
env_vars: dict[str, str] | None = None,
copy: bool = False,
) -> bool:
"""Generate MCP configuration JSON for manual installation.
Args:
file: Path to the server file
server_object: Optional server object name (for :object suffix)
name: Name for the server in MCP config
with_editable: Optional directory to install in editable mode
with_packages: Optional list of additional packages to install
env_vars: Optional dictionary of environment variables
copy: If True, copy to clipboard instead of printing to stdout
Returns:
True if generation was successful, False otherwise
"""
try:
# Build uv run command
args = ["run"]
# Collect all packages in a set to deduplicate
packages = {"fastmcp"}
if with_packages:
packages.update(pkg for pkg in with_packages if pkg)
# Add all packages with --with
for pkg in sorted(packages):
args.extend(["--with", pkg])
if with_editable:
args.extend(["--with-editable", str(with_editable)])
# Build server spec from parsed components
if server_object:
server_spec = f"{file.resolve()}:{server_object}"
else:
server_spec = str(file.resolve())
# Add fastmcp run command
args.extend(["fastmcp", "run", server_spec])
# Build MCP server configuration (just the server object, not the wrapper)
config = {
"command": "uv",
"args": args,
}
# Add environment variables if provided
if env_vars:
config["env"] = env_vars
# Convert to JSON
json_output = json.dumps(config, indent=2)
# Handle output
if copy:
try:
import pyperclip
pyperclip.copy(json_output)
print(
f"[green]MCP configuration for '[bold]{name}[/bold]' 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
else:
# Print to stdout (for piping)
print(json_output)
return True
except Exception as e:
print(f"[red]❌ Failed to generate MCP configuration: {e}[/red]")
return False
def mcp_config_command(
server_spec: Annotated[
str, typer.Argument(help="Python file to run, optionally with :object suffix")
],
server_name: Annotated[
str | None,
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to server's name attribute or file name)",
),
] = None,
with_editable: Annotated[
Path | None,
typer.Option(
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
with_packages: Annotated[
list[str],
typer.Option(
"--with", help="Additional packages to install, in PEP 508 format"
),
] = [],
env_vars: Annotated[
list[str],
typer.Option(
"--env-var", "-v", help="Environment variables in KEY=VALUE format"
),
] = [],
env_file: Annotated[
Path | None,
typer.Option(
"--env-file",
"-f",
help="Load environment variables from a .env file",
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
),
] = None,
copy: Annotated[
bool,
typer.Option(
"--copy",
help="Copy configuration to clipboard instead of printing to stdout",
),
] = False,
) -> None:
"""Generate MCP configuration JSON for manual installation."""
file, server_object, name, packages, env_dict = process_common_args(
server_spec, server_name, with_packages, env_vars, env_file
)
success = install_mcp_config(
file=file,
server_object=server_object,
name=name,
with_editable=with_editable,
with_packages=packages,
env_vars=env_dict,
copy=copy,
)
# mcp-config handles its own messaging, no generic success message needed
if not success:
sys.exit(1)

View file

@ -0,0 +1,87 @@
"""Shared utilities for install commands."""
from __future__ import annotations
import sys
from pathlib import Path
from dotenv import dotenv_values
from rich import print
from fastmcp.cli.run import import_server, parse_file_path
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
def parse_env_var(env_var: str) -> tuple[str, str]:
"""Parse environment variable string in format KEY=VALUE."""
if "=" not in env_var:
print(
f"[red]❌ Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]"
)
sys.exit(1)
key, value = env_var.split("=", 1)
return key.strip(), value.strip()
def process_common_args(
server_spec: str,
server_name: str | None,
with_packages: list[str],
env_vars: list[str],
env_file: Path | None,
) -> tuple[Path, str | None, str, list[str], dict[str, str] | None]:
"""Process common arguments shared by all install commands."""
# Parse server spec
file, server_object = parse_file_path(server_spec)
logger.debug(
"Installing server",
extra={
"file": str(file),
"server_name": server_name,
"server_object": server_object,
"with_packages": with_packages,
},
)
# Try to import server to get its name and dependencies
name = server_name
server = None
if not name:
try:
server = import_server(file, server_object)
name = server.name
except (ImportError, ModuleNotFoundError) as e:
logger.debug(
"Could not import server (likely missing dependencies), using file name",
extra={"error": str(e)},
)
name = file.stem
# Get server dependencies if available
server_dependencies = getattr(server, "dependencies", []) if server else []
if server_dependencies:
with_packages = list(set(with_packages + server_dependencies))
# Process environment variables if provided
env_dict: dict[str, str] | None = None
if env_file or env_vars:
env_dict = {}
# Load from .env file if specified
if env_file:
try:
env_dict |= {
k: v for k, v in dotenv_values(env_file).items() if v is not None
}
except Exception as e:
print(f"[red]❌ Failed to load .env file: {e}[/red]")
sys.exit(1)
# Add command line environment variables
for env_var in env_vars:
key, value = parse_env_var(env_var)
env_dict[key] = value
return file, server_object, name, with_packages, env_dict