Merge pull request #27 from jlowin/env

Support env vars when installing
This commit is contained in:
Jeremiah Lowin 2024-11-30 22:22:03 -05:00 committed by GitHub
commit ce8e4d7fba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 371 additions and 31 deletions

View file

@ -61,9 +61,14 @@ FastMCP handles all the complex protocol details and server management, so you c
- [Prompts](#prompts)
- [Images](#images)
- [Context](#context)
- [Environment Variables](#environment-variables)
- [Claude Desktop](#claude-desktop)
- [Development Mode](#development-mode)
- [Deployment](#deployment)
- [Development](#development)
- [Claude Desktop](#claude-desktop)
- [Environment Variables](#environment-variables-1)
- [Claude Desktop](#claude-desktop-1)
- [Environment Variables](#environment-variables-2)
- [Examples](#examples)
- [Echo Server](#echo-server)
- [SQLite Explorer](#sqlite-explorer)
@ -277,6 +282,42 @@ The Context object provides:
- Resource access through `read_resource()`
- Request metadata via `request_id` and `client_id`
## Environment Variables
MCP servers run in isolated environments and do not inherit environment variables from your system. Here's how to handle environment variables in different contexts:
### Claude Desktop
When installing a server in Claude Desktop, provide environment variables using the CLI:
```bash
# Single env var
fastmcp install server.py -e API_KEY=abc123
# Multiple env vars
fastmcp install server.py -e API_KEY=abc123 -e OTHER_VAR=value
# Load from .env file
fastmcp install server.py -f .env
```
Environment variables persist across reinstalls and are only updated when new values are provided:
```bash
# First install
fastmcp install server.py -e FOO=bar -e BAZ=123
# Second install - FOO and BAZ are preserved
fastmcp install server.py -e NEW=value
# Third install - FOO gets new value, others preserved
fastmcp install server.py -e FOO=newvalue
```
### Development Mode
The MCP Inspector also runs servers in an isolated environment. Environment variables must be set through the Inspector UI and are not inherited from your system. The Inspector does not currently support setting environment variables via command line (see [Issue #94](https://github.com/modelcontextprotocol/inspector/issues/94)).
## Deployment
The FastMCP CLI helps you develop and deploy MCP servers.
@ -306,6 +347,10 @@ fastmcp dev server.py --with pandas --with numpy
fastmcp dev server.py --with-editable .
```
#### Environment Variables
The MCP Inspector runs servers in an isolated environment. Environment variables must be set through the Inspector UI and are not inherited from your system. The Inspector does not currently support setting environment variables via command line (see [Issue #94](https://github.com/modelcontextprotocol/inspector/issues/94)).
### Claude Desktop
Install your server in Claude Desktop:
@ -318,9 +363,6 @@ fastmcp install server.py --name "My Server"
# With dependencies
fastmcp install server.py --with pandas --with numpy
# Replace an existing server
fastmcp install server.py --force
```
The server name in Claude will be:
@ -328,8 +370,38 @@ The server name in Claude will be:
2. The `name` from your FastMCP instance
3. The filename if the server can't be imported
#### Environment Variables
Claude Desktop runs servers in an isolated environment. Environment variables from your system are NOT automatically available to the server - you must explicitly provide them during installation:
```bash
# Single env var
fastmcp install server.py -e API_KEY=abc123
# Multiple env vars
fastmcp install server.py -e API_KEY=abc123 -e OTHER_VAR=value
# Load from .env file
fastmcp install server.py -f .env
```
Environment variables persist across reinstalls and are only updated when new values are provided:
```bash
# First install
fastmcp install server.py -e FOO=bar -e BAZ=123
# Second install - FOO and BAZ are preserved
fastmcp install server.py -e NEW=value
# Third install - FOO gets new value, others preserved
fastmcp install server.py -e FOO=newvalue
```
## Examples
Here are a few examples of FastMCP servers. For more, see the `examples/` directory.
### Echo Server
A simple server demonstrating resources, tools, and prompts:

View file

@ -9,6 +9,7 @@ dependencies = [
"pydantic-settings>=2.6.1",
"pydantic>=2.5.3,<3.0.0",
"typer>=0.9.0",
"python-dotenv>=1.0.1",
]
requires-python = ">=3.10"
readme = "README.md"

View file

@ -3,7 +3,7 @@
import json
import sys
from pathlib import Path
from typing import Optional
from typing import Optional, Dict
from ..utilities.logging import get_logger
@ -30,16 +30,17 @@ def update_claude_config(
*,
with_editable: Optional[Path] = None,
with_packages: Optional[list[str]] = None,
force: bool = False,
env_vars: Optional[Dict[str, str]] = None,
) -> bool:
"""Add the MCP server to Claude's configuration.
"""Add or update a FastMCP server in Claude's configuration.
Args:
file_spec: Path to the server file, optionally with :object suffix
server_name: Name for the server in Claude's config
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
env_vars: Optional dictionary of environment variables. These are merged with
any existing variables, with new values taking precedence.
"""
config_dir = get_claude_config_path()
if not config_dir:
@ -54,18 +55,17 @@ def update_claude_config(
if "mcpServers" not in config:
config["mcpServers"] = {}
if server_name in config["mcpServers"]:
if not force:
logger.warning(
f"Server '{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 '{server_name}' in Claude config",
extra={"config_file": str(config_file)},
)
# Always preserve existing env vars and merge with new ones
if (
server_name in config["mcpServers"]
and "env" in config["mcpServers"][server_name]
):
existing_env = config["mcpServers"][server_name]["env"]
if env_vars:
# New vars take precedence over existing ones
env_vars = {**existing_env, **env_vars}
else:
env_vars = existing_env
# Build uv run command
args = ["run", "--with", "fastmcp"]
@ -89,11 +89,17 @@ def update_claude_config(
# Add fastmcp run command
args.extend(["fastmcp", "run", file_spec])
config["mcpServers"][server_name] = {
server_config = {
"command": "uv",
"args": args,
}
# Add environment variables if specified
if env_vars:
server_config["env"] = env_vars
config["mcpServers"][server_name] = server_config
config_file.write_text(json.dumps(config, indent=2))
logger.info(
f"Added server '{server_name}' to Claude config",

View file

@ -5,10 +5,11 @@ import importlib.util
import subprocess
import sys
from pathlib import Path
from typing import Optional, Tuple
from typing import Optional, Tuple, Dict
import typer
from typing_extensions import Annotated
import dotenv
from ..utilities.logging import get_logger
from . import claude
@ -23,6 +24,17 @@ app = typer.Typer(
)
def _parse_env_var(env_var: str) -> Tuple[str, str]:
"""Parse environment variable string in format KEY=VALUE."""
if "=" not in env_var:
logger.error(
f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
)
sys.exit(1)
key, value = env_var.split("=", 1)
return key.strip(), value.strip()
def _build_uv_command(
file_spec: str,
with_editable: Optional[Path] = None,
@ -304,16 +316,32 @@ def install(
help="Additional packages to install",
),
] = [],
force: Annotated[
bool,
env_vars: Annotated[
list[str],
typer.Option(
"--force",
"-f",
help="Replace existing server if one exists with the same name",
"--env-var",
"-e",
help="Environment variables in KEY=VALUE format",
),
] = False,
] = [],
env_file: Annotated[
Optional[Path],
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 FastMCP server in the Claude desktop app."""
"""Install a FastMCP server in the Claude desktop app.
Environment variables are preserved once added and only updated if new values
are explicitly provided.
"""
file, server_object = _parse_file_path(file_spec)
logger.debug(
@ -324,7 +352,6 @@ def install(
"server_object": server_object,
"with_editable": str(with_editable) if with_editable else None,
"with_packages": with_packages,
"force": force,
},
)
@ -345,12 +372,29 @@ def install(
)
name = file.stem
# Process environment variables if provided
env_dict: Optional[Dict[str, str]] = None
if env_file or env_vars:
env_dict = {}
# Load from .env file if specified
if env_file:
try:
env_dict.update(dotenv.dotenv_values(env_file))
except Exception as e:
logger.error(f"Failed to load .env file: {e}")
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
if claude.update_claude_config(
file_spec,
name,
with_editable=with_editable,
with_packages=with_packages,
force=force,
env_vars=env_dict,
):
logger.info(f"Successfully installed {name} in Claude app")
else:

215
tests/test_cli.py Normal file
View file

@ -0,0 +1,215 @@
"""Tests for the FastMCP CLI."""
import json
from unittest.mock import Mock, patch
import pytest
from typer.testing import CliRunner
from fastmcp.cli.cli import app, _parse_env_var
@pytest.fixture
def mock_config(tmp_path):
"""Create a mock Claude config file."""
config = {"mcpServers": {}}
config_file = tmp_path / "claude_desktop_config.json"
config_file.write_text(json.dumps(config))
return config_file
@pytest.fixture
def mock_server_file(tmp_path):
"""Create a mock server file."""
server_file = tmp_path / "server.py"
server_file.write_text(
"from fastmcp import Server\n" "server = Server(name='test')\n"
)
return server_file
@pytest.fixture
def mock_env_file(tmp_path):
"""Create a mock .env file."""
env_file = tmp_path / ".env"
env_file.write_text("FOO=bar\nBAZ=123")
return env_file
def test_parse_env_var():
"""Test parsing environment variables."""
assert _parse_env_var("FOO=bar") == ("FOO", "bar")
assert _parse_env_var("FOO=") == ("FOO", "")
assert _parse_env_var("FOO=bar baz") == ("FOO", "bar baz")
assert _parse_env_var("FOO = bar ") == ("FOO", "bar")
with pytest.raises(SystemExit):
_parse_env_var("invalid")
@pytest.mark.parametrize(
"args,expected_env",
[
# Basic env var
(
["--env-var", "FOO=bar"],
{"FOO": "bar"},
),
# Multiple env vars
(
["--env-var", "FOO=bar", "--env-var", "BAZ=123"],
{"FOO": "bar", "BAZ": "123"},
),
# Env var with spaces
(
["--env-var", "FOO=bar baz"],
{"FOO": "bar baz"},
),
],
)
def test_install_with_env_vars(mock_config, mock_server_file, args, expected_env):
"""Test installing with environment variables."""
runner = CliRunner()
with (
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
patch("fastmcp.cli.cli._import_server") as mock_import,
):
mock_config_path.return_value = mock_config.parent
mock_server = Mock()
mock_server.name = "test" # Set name as an attribute
mock_import.return_value = mock_server
result = runner.invoke(
app,
["install", str(mock_server_file)] + args,
)
assert result.exit_code == 0
# Read the config file and check env vars
config = json.loads(mock_config.read_text())
assert "mcpServers" in config
assert len(config["mcpServers"]) == 1
server = next(iter(config["mcpServers"].values()))
assert server["env"] == expected_env
def test_install_with_env_file(mock_config, mock_server_file, mock_env_file):
"""Test installing with environment variables from a file."""
runner = CliRunner()
with (
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
patch("fastmcp.cli.cli._import_server") as mock_import,
):
mock_config_path.return_value = mock_config.parent
mock_server = Mock()
mock_server.name = "test" # Set name as an attribute
mock_import.return_value = mock_server
result = runner.invoke(
app,
["install", str(mock_server_file), "--env-file", str(mock_env_file)],
)
assert result.exit_code == 0
# Read the config file and check env vars
config = json.loads(mock_config.read_text())
assert "mcpServers" in config
assert len(config["mcpServers"]) == 1
server = next(iter(config["mcpServers"].values()))
assert server["env"] == {"FOO": "bar", "BAZ": "123"}
def test_install_preserves_existing_env_vars(mock_config, mock_server_file):
"""Test that installing preserves existing environment variables."""
# Set up initial config with env vars
config = {
"mcpServers": {
"test": {
"command": "uv",
"args": [
"run",
"--with",
"fastmcp",
"fastmcp",
"run",
str(mock_server_file),
],
"env": {"FOO": "bar", "BAZ": "123"},
}
}
}
mock_config.write_text(json.dumps(config))
runner = CliRunner()
with (
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
patch("fastmcp.cli.cli._import_server") as mock_import,
):
mock_config_path.return_value = mock_config.parent
mock_server = Mock()
mock_server.name = "test" # Set name as an attribute
mock_import.return_value = mock_server
# Install with a new env var
result = runner.invoke(
app,
["install", str(mock_server_file), "--env-var", "NEW=value"],
)
assert result.exit_code == 0
# Read the config file and check env vars are preserved
config = json.loads(mock_config.read_text())
server = next(iter(config["mcpServers"].values()))
assert server["env"] == {"FOO": "bar", "BAZ": "123", "NEW": "value"}
def test_install_updates_existing_env_vars(mock_config, mock_server_file):
"""Test that installing updates existing environment variables."""
# Set up initial config with env vars
config = {
"mcpServers": {
"test": {
"command": "uv",
"args": [
"run",
"--with",
"fastmcp",
"fastmcp",
"run",
str(mock_server_file),
],
"env": {"FOO": "bar", "BAZ": "123"},
}
}
}
mock_config.write_text(json.dumps(config))
runner = CliRunner()
with (
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
patch("fastmcp.cli.cli._import_server") as mock_import,
):
mock_config_path.return_value = mock_config.parent
mock_server = Mock()
mock_server.name = "test" # Set name as an attribute
mock_import.return_value = mock_server
# Update an existing env var
result = runner.invoke(
app,
["install", str(mock_server_file), "--env-var", "FOO=newvalue"],
)
assert result.exit_code == 0
# Read the config file and check env var was updated
config = json.loads(mock_config.read_text())
server = next(iter(config["mcpServers"].values()))
assert server["env"] == {"FOO": "newvalue", "BAZ": "123"}

4
uv.lock generated
View file

@ -228,13 +228,14 @@ wheels = [
[[package]]
name = "fastmcp"
version = "0.3.1.dev3+g35b0931.d20241201"
version = "0.3.2.dev0+g5656200.d20241201"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "typer" },
]
@ -263,6 +264,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.1" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "ruff", marker = "extra == 'dev'" },
{ name = "typer", specifier = ">=0.9.0" },
]