Add readme

This commit is contained in:
Jeremiah Lowin 2024-11-29 20:47:23 -05:00
commit 4f182ab66a
7 changed files with 541 additions and 69 deletions

109
README.md
View file

@ -1,2 +1,107 @@
Notes:
- uv must be installed with brew to run local servers
# FastMCP
> **Note**: This is experimental software. The Model Context Protocol itself is only a few days old and the specification is still evolving.
A fast, pythonic way to build Model Context Protocol (MCP) servers.
The Model Context Protocol is an extremely powerful way to give LLMs access to tools and resources. However, building MCP servers can be difficult and cumbersome. FastMCP provides a simple, intuitive interface for creating MCP servers in Python.
## Installation
MCP servers require you to use [uv](https://github.com/astral-sh/uv) as your dependency manager.
Install uv with brew:
```bash
brew install uv
```
*(Editor's note: I was unable to get MCP servers working unless uv was installed with brew.)*
Install FastMCP:
```bash
uv pip install fastmcp
```
## Quick Start
Here's a simple example that exposes your desktop directory as a resource and provides a basic addition tool:
```python
from pathlib import Path
from fastmcp import FastMCP
# Create server
mcp = FastMCP("Demo")
@mcp.resource("dir://desktop")
def desktop() -> list[str]:
"""List the files in the user's desktop"""
desktop = Path.home() / "Desktop"
return [str(f) for f in desktop.iterdir()]
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers"""
return a + b
if __name__ == "__main__":
mcp.run()
```
## Features
### Resources
Resources are data sources that can be accessed by the LLM. They can be files, directories, or any other data source. Resources are defined using the `@resource` decorator:
```python
@mcp.resource("file://config.json")
def get_config() -> str:
"""Read the config file"""
return Path("config.json").read_text()
```
### Tools
Tools are functions that can be called by the LLM. They are defined using the `@tool` decorator:
```python
@mcp.tool()
def calculate(x: int, y: int) -> int:
"""Perform a calculation"""
return x + y
```
## Development
### Running the Dev Inspector
FastMCP includes a development server with the MCP Inspector for testing your server:
```bash
fastmcp dev your_server.py
```
### Installing in Claude
To use your server with Claude Desktop:
```bash
fastmcp install your_server.py --name "My Server"
```
## Configuration
FastMCP can be configured via environment variables with the prefix `FASTMCP_`:
- `FASTMCP_DEBUG`: Enable debug mode
- `FASTMCP_LOG_LEVEL`: Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
- `FASTMCP_HOST`: HTTP server host (default: 0.0.0.0)
- `FASTMCP_PORT`: HTTP server port (default: 8000)
## License
Apache 2.0

34
examples/screenshot.py Normal file
View file

@ -0,0 +1,34 @@
# /// script
# dependencies = ["pyautogui"]
# ///
"""
FastMCP Screenshot Example
A simple example that provides a tool to capture screenshots.
"""
import base64
import io
import pyautogui
from fastmcp.server import FastMCP
# Create server
mcp = FastMCP("Screenshot Demo")
@mcp.tool()
def take_screenshot() -> str:
"""Take a screenshot and return it as a base64 encoded string"""
# Capture the screen
screenshot = pyautogui.screenshot()
# Convert to base64
buffer = io.BytesIO()
screenshot.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode()
if __name__ == "__main__":
mcp.run()

View file

@ -1,72 +1,6 @@
"""FastMCP CLI tools."""
import importlib.metadata
import subprocess
import sys
from pathlib import Path
import typer
from .utilities.logging import get_logger
logger = get_logger(__name__)
app = typer.Typer(
name="fastmcp",
help="FastMCP development tools",
add_completion=False,
no_args_is_help=True, # Show help if no args provided
)
@app.command()
def version() -> None:
"""Show the FastMCP version."""
try:
version = importlib.metadata.version("fastmcp")
print(f"FastMCP version {version}")
except importlib.metadata.PackageNotFoundError:
print("FastMCP version unknown (package not installed)")
sys.exit(1)
@app.command()
def dev(
file: Path = typer.Argument(
...,
help="Python file to run",
exists=True,
dir_okay=False,
resolve_path=True,
),
) -> None:
"""Run a FastMCP server with the MCP Inspector."""
logger.debug("Starting dev server", extra={"file": str(file)})
try:
# Run the MCP Inspector command
process = subprocess.run(
["npx", "@modelcontextprotocol/inspector", "uv", "run", str(file)],
check=True,
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
logger.error(
"Dev server failed",
extra={
"file": str(file),
"error": str(e),
"returncode": e.returncode,
},
)
sys.exit(e.returncode)
except FileNotFoundError:
logger.error(
"npx not found. Please install Node.js and npm.",
extra={"file": str(file)},
)
sys.exit(1)
from .cli.cli import app
if __name__ == "__main__":
app()

View file

@ -0,0 +1,5 @@
"""FastMCP CLI package."""
from .cli import app
__all__ = ["app"]

88
src/fastmcp/cli/claude.py Normal file
View file

@ -0,0 +1,88 @@
"""Claude app integration utilities."""
import json
import sys
from pathlib import Path
from typing import Optional
from ..utilities.logging import get_logger
logger = get_logger(__name__)
def get_claude_config_path() -> Path | None:
"""Get the Claude config directory based on platform."""
if sys.platform == "win32":
path = Path(Path.home(), "AppData", "Roaming", "Claude")
elif sys.platform == "darwin":
path = Path(Path.home(), "Library", "Application Support", "Claude")
else:
return None
if path.exists():
return path
return None
def update_claude_config(
file: Path,
server_name: Optional[str] = None,
*,
uv_directory: Optional[Path] = None,
) -> bool:
"""Add the MCP server to Claude's configuration.
Args:
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
"""
config_dir = get_claude_config_path()
if not config_dir:
return False
config_file = config_dir / "claude_desktop_config.json"
if not config_file.exists():
return False
try:
config = json.loads(config_file.read_text())
if "mcpServers" not in config:
config["mcpServers"] = {}
# 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",
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)])
config["mcpServers"][name] = {
"command": "uv",
"args": args,
}
config_file.write_text(json.dumps(config, indent=2))
logger.info(
f"Added server '{name}' to Claude config",
extra={"config_file": str(config_file)},
)
return True
except Exception as e:
logger.error(
"Failed to update Claude config",
extra={
"error": str(e),
"config_file": str(config_file),
},
)
return False

306
src/fastmcp/cli/cli.py Normal file
View file

@ -0,0 +1,306 @@
"""FastMCP CLI tools."""
import importlib.metadata
import importlib.util
import subprocess
import sys
from pathlib import Path
from typing import Optional, Tuple
import typer
from typing_extensions import Annotated
from ..utilities.logging import get_logger
from . import claude
logger = get_logger(__name__)
app = typer.Typer(
name="fastmcp",
help="FastMCP development tools",
add_completion=False,
no_args_is_help=True, # Show help if no args provided
)
def _build_uv_command(
file: Path,
uv_directory: Optional[Path] = None,
) -> list[str]:
"""Build the uv run command."""
cmd = ["uv"]
if uv_directory:
cmd.extend(["--directory", str(uv_directory)])
cmd.extend(["run", str(file)])
return cmd
def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]:
"""Parse a file path that may include a server object specification.
Args:
file_spec: Path to file, optionally with :object suffix
Returns:
Tuple of (file_path, server_object)
"""
if ":" in file_spec:
file_str, server_object = file_spec.rsplit(":", 1)
else:
file_str, server_object = file_spec, None
file_path = Path(file_str).expanduser().resolve()
if not file_path.exists():
logger.error(f"File not found: {file_path}")
sys.exit(1)
if not file_path.is_file():
logger.error(f"Not a file: {file_path}")
sys.exit(1)
return file_path, server_object
def _import_server(file: Path, server_object: Optional[str] = None):
"""Import a FastMCP server from a file.
Args:
file: Path to the file
server_object: Optional object name in format "module:object" or just "object"
Returns:
The server object
"""
# Import the module
spec = importlib.util.spec_from_file_location("server_module", file)
if not spec or not spec.loader:
logger.error("Could not load module", extra={"file": str(file)})
sys.exit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# If no object specified, try __main__ block
if not server_object:
# Look for the most common server object names
for name in ["mcp", "server", "app"]:
if hasattr(module, name):
return getattr(module, name)
logger.error(
f"No server object found in {file}. Please specify the object name with file:object syntax.",
extra={"file": str(file)},
)
sys.exit(1)
# Handle module:object syntax
if ":" in server_object:
module_name, object_name = server_object.split(":", 1)
try:
server_module = importlib.import_module(module_name)
server = getattr(server_module, object_name, None)
except ImportError:
logger.error(
f"Could not import module '{module_name}'",
extra={"file": str(file)},
)
sys.exit(1)
else:
# Just object name
server = getattr(module, server_object, None)
if server is None:
logger.error(
f"Server object '{server_object}' not found",
extra={"file": str(file)},
)
sys.exit(1)
return server
@app.command()
def version() -> None:
"""Show the FastMCP version."""
try:
version = importlib.metadata.version("fastmcp")
print(f"FastMCP version {version}")
except importlib.metadata.PackageNotFoundError:
print("FastMCP version unknown (package not installed)")
sys.exit(1)
@app.command()
def dev(
file_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
uv_directory: Annotated[
Optional[Path],
typer.Option(
"--uv-directory",
"-d",
help="Directory containing pyproject.toml (defaults to current directory)",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Run a FastMCP server with the MCP Inspector."""
file, server_object = _parse_file_path(file_spec)
logger.debug(
"Starting dev server",
extra={
"file": str(file),
"server_object": server_object,
"uv_directory": str(uv_directory) if uv_directory else None,
},
)
try:
uv_cmd = _build_uv_command(file, uv_directory)
# Run the MCP Inspector command
process = subprocess.run(
["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
check=True,
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
logger.error(
"Dev server failed",
extra={
"file": str(file),
"error": str(e),
"returncode": e.returncode,
},
)
sys.exit(e.returncode)
except FileNotFoundError:
logger.error(
"npx not found. Please install Node.js and npm.",
extra={"file": str(file)},
)
sys.exit(1)
@app.command()
def run(
file_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
transport: Annotated[
Optional[str],
typer.Option(
"--transport",
"-t",
help="Transport protocol to use (stdio or sse)",
),
] = None,
uv_directory: Annotated[
Optional[Path],
typer.Option(
"--uv-directory",
"-d",
help="Directory containing pyproject.toml (defaults to current directory)",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Run a FastMCP server."""
file, server_object = _parse_file_path(file_spec)
logger.debug(
"Running server",
extra={
"file": str(file),
"server_object": server_object,
"transport": transport,
"uv_directory": str(uv_directory) if uv_directory else None,
},
)
try:
uv_cmd = _build_uv_command(file, uv_directory)
# Import and get server object
server = _import_server(file, server_object)
# Run the server
kwargs = {}
if transport:
kwargs["transport"] = transport
server.run(**kwargs)
except Exception as e:
logger.error(
"Failed to run server",
extra={
"file": str(file),
"error": str(e),
},
)
sys.exit(1)
@app.command()
def install(
file_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
server_name: Annotated[
Optional[str],
typer.Option(
"--name",
"-n",
help="Custom name for the server (defaults to file name)",
),
] = None,
uv_directory: Annotated[
Optional[Path],
typer.Option(
"--uv-directory",
"-d",
help="Directory containing pyproject.toml (defaults to current directory)",
exists=True,
file_okay=False,
resolve_path=True,
),
] = None,
) -> None:
"""Install a FastMCP server in the Claude desktop app."""
file, server_object = _parse_file_path(file_spec)
logger.debug(
"Installing server",
extra={
"file": str(file),
"server_name": server_name,
"server_object": server_object,
"uv_directory": str(uv_directory) if uv_directory else None,
},
)
if not claude.get_claude_config_path():
logger.error("Claude app not found")
sys.exit(1)
if claude.update_claude_config(file, server_name, uv_directory=uv_directory):
name = server_name or file.stem
print(f"Successfully installed {name} in Claude app")
else:
name = server_name or file.stem
print(f"Failed to install {name} in Claude app")
sys.exit(1)
if __name__ == "__main__":
app()