Improve handling of editable installs

This commit is contained in:
Jeremiah Lowin 2024-11-29 22:26:27 -05:00
commit 0eaa84131a
7 changed files with 129 additions and 54 deletions

View file

@ -84,24 +84,34 @@ FastMCP includes a development server with the MCP Inspector for testing your se
# Basic usage
fastmcp dev your_server.py
# Load dependencies from current directory's pyproject.toml
fastmcp dev your_server.py --uv-directory .
# Install package in editable mode from current directory
fastmcp dev your_server.py --with-editable .
# Install additional packages
fastmcp dev your_server.py --with pandas,numpy
fastmcp dev your_server.py --with pandas --with numpy
# Combine both
fastmcp dev your_server.py --uv-directory . --with pandas,numpy
fastmcp dev your_server.py --with-editable . --with pandas --with numpy
```
The `--with` flag automatically includes `fastmcp` and any additional packages you specify. The `--uv-directory` flag tells uv where to find your project's dependencies.
The `--with` flag automatically includes `fastmcp` and any additional packages you specify. The `--with-editable` flag installs the package from the specified directory in editable mode, which is useful during development.
### Installing in Claude
To use your server with Claude Desktop:
```bash
# Basic usage
fastmcp install your_server.py --name "My Server"
# Install package in editable mode
fastmcp install your_server.py --with-editable .
# Install additional packages
fastmcp install your_server.py --with pandas --with numpy
# Combine options
fastmcp install your_server.py --with-editable . --with pandas --with numpy
```

View file

@ -1 +1,2 @@
from .server import FastMCP
from .server import FastMCP
from .tools import Image

View file

@ -28,7 +28,9 @@ def update_claude_config(
file: Path,
server_name: Optional[str] = None,
*,
uv_directory: Optional[Path] = None,
with_editable: Optional[Path] = None,
with_packages: Optional[list[str]] = None,
force: bool = False,
) -> bool:
"""Add the MCP server to Claude's configuration.
@ -36,7 +38,9 @@ def update_claude_config(
file: Path to the server file
server_name: Optional custom name for the server. If not provided,
defaults to the file stem
uv_directory: Optional directory containing pyproject.toml
with_editable: Optional directory to install in editable mode
with_packages: Optional list of additional packages to install
force: If True, replace existing server with same name
"""
config_dir = get_claude_config_path()
if not config_dir:
@ -54,17 +58,34 @@ def update_claude_config(
# Use provided server_name or fall back to file stem
name = server_name or file.stem
if name in config["mcpServers"]:
logger.warning(
f"Server '{name}' already exists in Claude config",
if not force:
logger.warning(
f"Server '{name}' already exists in Claude config. "
"Use `--force` to replace.",
extra={"config_file": str(config_file)},
)
return False
logger.info(
f"Replacing existing server '{name}' in Claude config",
extra={"config_file": str(config_file)},
)
return False
# Build uv run command
args = []
if uv_directory:
args.extend(["--directory", str(uv_directory)])
args.extend(["run", str(file)])
args = ["run"]
if with_editable:
args.extend(["--with-editable", str(with_editable)])
# Always include fastmcp
args.extend(["--with", "fastmcp"])
# Add additional packages
if with_packages:
for pkg in with_packages:
if pkg:
args.extend(["--with", pkg])
args.append(str(file))
config["mcpServers"][name] = {
"command": "uv",

View file

@ -25,17 +25,17 @@ app = typer.Typer(
def _build_uv_command(
file: Path,
uv_directory: Optional[Path] = None,
with_editable: Optional[Path] = None,
with_packages: Optional[list[str]] = None,
) -> list[str]:
"""Build the uv run command."""
cmd = ["uv"]
if uv_directory:
cmd.extend(["--directory", str(uv_directory)])
cmd.extend(["run", "--with", "fastmcp"])
if with_editable:
cmd.extend(["--with-editable", str(with_editable)])
if with_packages:
for pkg in with_packages:
if pkg:
@ -145,12 +145,12 @@ def dev(
...,
help="Python file to run, optionally with :object suffix",
),
uv_directory: Annotated[
with_editable: Annotated[
Optional[Path],
typer.Option(
"--uv-directory",
"-d",
help="Directory containing pyproject.toml (defaults to current directory)",
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode",
exists=True,
file_okay=False,
resolve_path=True,
@ -172,13 +172,13 @@ def dev(
extra={
"file": str(file),
"server_object": server_object,
"uv_directory": str(uv_directory) if uv_directory else None,
"with_editable": str(with_editable) if with_editable else None,
"with_packages": with_packages,
},
)
try:
uv_cmd = _build_uv_command(file, uv_directory, with_packages)
uv_cmd = _build_uv_command(file, with_editable, with_packages)
# Run the MCP Inspector command
process = subprocess.run(
["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
@ -217,12 +217,12 @@ def run(
help="Transport protocol to use (stdio or sse)",
),
] = None,
uv_directory: Annotated[
with_editable: Annotated[
Optional[Path],
typer.Option(
"--uv-directory",
"-d",
help="Directory containing pyproject.toml (defaults to current directory)",
"--with-editable",
"-e",
help="Directory containing pyproject.toml to install in editable mode",
exists=True,
file_okay=False,
resolve_path=True,
@ -238,7 +238,7 @@ def run(
"file": str(file),
"server_object": server_object,
"transport": transport,
"uv_directory": str(uv_directory) if uv_directory else None,
"with_editable": str(with_editable) if with_editable else None,
},
)
@ -278,17 +278,32 @@ def install(
help="Custom name for the server (defaults to file name)",
),
] = None,
uv_directory: Annotated[
with_editable: Annotated[
Optional[Path],
typer.Option(
"--uv-directory",
"-d",
help="Directory containing pyproject.toml (defaults to current directory)",
"--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",
),
] = [],
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Replace existing server if one exists with the same name",
),
] = False,
) -> None:
"""Install a FastMCP server in the Claude desktop app."""
file, server_object = _parse_file_path(file_spec)
@ -299,7 +314,9 @@ def install(
"file": str(file),
"server_name": server_name,
"server_object": server_object,
"uv_directory": str(uv_directory) if uv_directory else None,
"with_editable": str(with_editable) if with_editable else None,
"with_packages": with_packages,
"force": force,
},
)
@ -307,7 +324,13 @@ def install(
logger.error("Claude app not found")
sys.exit(1)
if claude.update_claude_config(file, server_name, uv_directory=uv_directory):
if claude.update_claude_config(
file,
server_name,
with_editable=with_editable,
with_packages=with_packages,
force=force,
):
name = server_name or file.stem
print(f"Successfully installed {name} in Claude app")
else:

View file

@ -17,26 +17,46 @@ logger = get_logger(__name__)
class Image:
"""Helper class for returning images from tools."""
def __init__(self, path: Union[str, Path], mime_type: Optional[str] = None):
self.path = Path(path)
self.mime_type = mime_type or self._guess_mime_type()
def __init__(
self,
path: Optional[Union[str, Path]] = None,
data: Optional[bytes] = None,
format: Optional[str] = None,
):
if path is None and data is None:
raise ValueError("Either path or data must be provided")
if path is not None and data is not None:
raise ValueError("Only one of path or data can be provided")
def _guess_mime_type(self) -> str:
"""Guess MIME type from file extension."""
suffix = self.path.suffix.lower()
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}.get(suffix, "application/octet-stream")
self.path = Path(path) if path else None
self.data = data
self._format = format
self._mime_type = self._get_mime_type()
def _get_mime_type(self) -> str:
"""Get MIME type from format or guess from file extension."""
if self._format:
return f"image/{self._format.lower()}"
if self.path:
suffix = self.path.suffix.lower()
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}.get(suffix, "application/octet-stream")
return "image/png" # default for raw binary data
def to_image_content(self) -> ImageContent:
"""Convert to MCP ImageContent."""
with open(self.path, "rb") as f:
data = base64.b64encode(f.read()).decode()
return ImageContent(type="image", data=data, mimeType=self.mime_type)
if self.path:
with open(self.path, "rb") as f:
data = base64.b64encode(f.read()).decode()
else:
data = base64.b64encode(self.data).decode()
return ImageContent(type="image", data=data, mimeType=self._mime_type)
class Tool(BaseModel):

2
uv.lock generated
View file

@ -222,7 +222,7 @@ wheels = [
[[package]]
name = "fastmcp"
version = "0.1.1.dev1+gca7438f.d20241130"
version = "0.1.1.dev3+gbffea91.d20241130"
source = { editable = "." }
dependencies = [
{ name = "httpx" },