Spaces:
Running
Running
Merge pull request #27 from jlowin/env
Browse files- README.md +76 -4
- pyproject.toml +1 -0
- src/fastmcp/cli/claude.py +23 -17
- src/fastmcp/cli/cli.py +53 -9
- tests/test_cli.py +215 -0
- uv.lock +3 -1
README.md
CHANGED
|
@@ -61,9 +61,14 @@ FastMCP handles all the complex protocol details and server management, so you c
|
|
| 61 |
- [Prompts](#prompts)
|
| 62 |
- [Images](#images)
|
| 63 |
- [Context](#context)
|
|
|
|
|
|
|
|
|
|
| 64 |
- [Deployment](#deployment)
|
| 65 |
- [Development](#development)
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
- [Examples](#examples)
|
| 68 |
- [Echo Server](#echo-server)
|
| 69 |
- [SQLite Explorer](#sqlite-explorer)
|
|
@@ -277,6 +282,42 @@ The Context object provides:
|
|
| 277 |
- Resource access through `read_resource()`
|
| 278 |
- Request metadata via `request_id` and `client_id`
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
## Deployment
|
| 281 |
|
| 282 |
The FastMCP CLI helps you develop and deploy MCP servers.
|
|
@@ -306,6 +347,10 @@ fastmcp dev server.py --with pandas --with numpy
|
|
| 306 |
fastmcp dev server.py --with-editable .
|
| 307 |
```
|
| 308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
### Claude Desktop
|
| 310 |
|
| 311 |
Install your server in Claude Desktop:
|
|
@@ -318,9 +363,6 @@ fastmcp install server.py --name "My Server"
|
|
| 318 |
|
| 319 |
# With dependencies
|
| 320 |
fastmcp install server.py --with pandas --with numpy
|
| 321 |
-
|
| 322 |
-
# Replace an existing server
|
| 323 |
-
fastmcp install server.py --force
|
| 324 |
```
|
| 325 |
|
| 326 |
The server name in Claude will be:
|
|
@@ -328,8 +370,38 @@ The server name in Claude will be:
|
|
| 328 |
2. The `name` from your FastMCP instance
|
| 329 |
3. The filename if the server can't be imported
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
## Examples
|
| 332 |
|
|
|
|
|
|
|
| 333 |
### Echo Server
|
| 334 |
A simple server demonstrating resources, tools, and prompts:
|
| 335 |
|
|
|
|
| 61 |
- [Prompts](#prompts)
|
| 62 |
- [Images](#images)
|
| 63 |
- [Context](#context)
|
| 64 |
+
- [Environment Variables](#environment-variables)
|
| 65 |
+
- [Claude Desktop](#claude-desktop)
|
| 66 |
+
- [Development Mode](#development-mode)
|
| 67 |
- [Deployment](#deployment)
|
| 68 |
- [Development](#development)
|
| 69 |
+
- [Environment Variables](#environment-variables-1)
|
| 70 |
+
- [Claude Desktop](#claude-desktop-1)
|
| 71 |
+
- [Environment Variables](#environment-variables-2)
|
| 72 |
- [Examples](#examples)
|
| 73 |
- [Echo Server](#echo-server)
|
| 74 |
- [SQLite Explorer](#sqlite-explorer)
|
|
|
|
| 282 |
- Resource access through `read_resource()`
|
| 283 |
- Request metadata via `request_id` and `client_id`
|
| 284 |
|
| 285 |
+
## Environment Variables
|
| 286 |
+
|
| 287 |
+
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:
|
| 288 |
+
|
| 289 |
+
### Claude Desktop
|
| 290 |
+
|
| 291 |
+
When installing a server in Claude Desktop, provide environment variables using the CLI:
|
| 292 |
+
|
| 293 |
+
```bash
|
| 294 |
+
# Single env var
|
| 295 |
+
fastmcp install server.py -e API_KEY=abc123
|
| 296 |
+
|
| 297 |
+
# Multiple env vars
|
| 298 |
+
fastmcp install server.py -e API_KEY=abc123 -e OTHER_VAR=value
|
| 299 |
+
|
| 300 |
+
# Load from .env file
|
| 301 |
+
fastmcp install server.py -f .env
|
| 302 |
+
```
|
| 303 |
+
|
| 304 |
+
Environment variables persist across reinstalls and are only updated when new values are provided:
|
| 305 |
+
|
| 306 |
+
```bash
|
| 307 |
+
# First install
|
| 308 |
+
fastmcp install server.py -e FOO=bar -e BAZ=123
|
| 309 |
+
|
| 310 |
+
# Second install - FOO and BAZ are preserved
|
| 311 |
+
fastmcp install server.py -e NEW=value
|
| 312 |
+
|
| 313 |
+
# Third install - FOO gets new value, others preserved
|
| 314 |
+
fastmcp install server.py -e FOO=newvalue
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
### Development Mode
|
| 318 |
+
|
| 319 |
+
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)).
|
| 320 |
+
|
| 321 |
## Deployment
|
| 322 |
|
| 323 |
The FastMCP CLI helps you develop and deploy MCP servers.
|
|
|
|
| 347 |
fastmcp dev server.py --with-editable .
|
| 348 |
```
|
| 349 |
|
| 350 |
+
#### Environment Variables
|
| 351 |
+
|
| 352 |
+
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)).
|
| 353 |
+
|
| 354 |
### Claude Desktop
|
| 355 |
|
| 356 |
Install your server in Claude Desktop:
|
|
|
|
| 363 |
|
| 364 |
# With dependencies
|
| 365 |
fastmcp install server.py --with pandas --with numpy
|
|
|
|
|
|
|
|
|
|
| 366 |
```
|
| 367 |
|
| 368 |
The server name in Claude will be:
|
|
|
|
| 370 |
2. The `name` from your FastMCP instance
|
| 371 |
3. The filename if the server can't be imported
|
| 372 |
|
| 373 |
+
#### Environment Variables
|
| 374 |
+
|
| 375 |
+
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:
|
| 376 |
+
|
| 377 |
+
```bash
|
| 378 |
+
# Single env var
|
| 379 |
+
fastmcp install server.py -e API_KEY=abc123
|
| 380 |
+
|
| 381 |
+
# Multiple env vars
|
| 382 |
+
fastmcp install server.py -e API_KEY=abc123 -e OTHER_VAR=value
|
| 383 |
+
|
| 384 |
+
# Load from .env file
|
| 385 |
+
fastmcp install server.py -f .env
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
Environment variables persist across reinstalls and are only updated when new values are provided:
|
| 389 |
+
|
| 390 |
+
```bash
|
| 391 |
+
# First install
|
| 392 |
+
fastmcp install server.py -e FOO=bar -e BAZ=123
|
| 393 |
+
|
| 394 |
+
# Second install - FOO and BAZ are preserved
|
| 395 |
+
fastmcp install server.py -e NEW=value
|
| 396 |
+
|
| 397 |
+
# Third install - FOO gets new value, others preserved
|
| 398 |
+
fastmcp install server.py -e FOO=newvalue
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
## Examples
|
| 402 |
|
| 403 |
+
Here are a few examples of FastMCP servers. For more, see the `examples/` directory.
|
| 404 |
+
|
| 405 |
### Echo Server
|
| 406 |
A simple server demonstrating resources, tools, and prompts:
|
| 407 |
|
pyproject.toml
CHANGED
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
| 9 |
"pydantic-settings>=2.6.1",
|
| 10 |
"pydantic>=2.5.3,<3.0.0",
|
| 11 |
"typer>=0.9.0",
|
|
|
|
| 12 |
]
|
| 13 |
requires-python = ">=3.10"
|
| 14 |
readme = "README.md"
|
|
|
|
| 9 |
"pydantic-settings>=2.6.1",
|
| 10 |
"pydantic>=2.5.3,<3.0.0",
|
| 11 |
"typer>=0.9.0",
|
| 12 |
+
"python-dotenv>=1.0.1",
|
| 13 |
]
|
| 14 |
requires-python = ">=3.10"
|
| 15 |
readme = "README.md"
|
src/fastmcp/cli/claude.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import json
|
| 4 |
import sys
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import Optional
|
| 7 |
|
| 8 |
from ..utilities.logging import get_logger
|
| 9 |
|
|
@@ -30,16 +30,17 @@ def update_claude_config(
|
|
| 30 |
*,
|
| 31 |
with_editable: Optional[Path] = None,
|
| 32 |
with_packages: Optional[list[str]] = None,
|
| 33 |
-
|
| 34 |
) -> bool:
|
| 35 |
-
"""Add
|
| 36 |
|
| 37 |
Args:
|
| 38 |
file_spec: Path to the server file, optionally with :object suffix
|
| 39 |
server_name: Name for the server in Claude's config
|
| 40 |
with_editable: Optional directory to install in editable mode
|
| 41 |
with_packages: Optional list of additional packages to install
|
| 42 |
-
|
|
|
|
| 43 |
"""
|
| 44 |
config_dir = get_claude_config_path()
|
| 45 |
if not config_dir:
|
|
@@ -54,18 +55,17 @@ def update_claude_config(
|
|
| 54 |
if "mcpServers" not in config:
|
| 55 |
config["mcpServers"] = {}
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
)
|
| 69 |
|
| 70 |
# Build uv run command
|
| 71 |
args = ["run", "--with", "fastmcp"]
|
|
@@ -89,11 +89,17 @@ def update_claude_config(
|
|
| 89 |
# Add fastmcp run command
|
| 90 |
args.extend(["fastmcp", "run", file_spec])
|
| 91 |
|
| 92 |
-
|
| 93 |
"command": "uv",
|
| 94 |
"args": args,
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
config_file.write_text(json.dumps(config, indent=2))
|
| 98 |
logger.info(
|
| 99 |
f"Added server '{server_name}' to Claude config",
|
|
|
|
| 3 |
import json
|
| 4 |
import sys
|
| 5 |
from pathlib import Path
|
| 6 |
+
from typing import Optional, Dict
|
| 7 |
|
| 8 |
from ..utilities.logging import get_logger
|
| 9 |
|
|
|
|
| 30 |
*,
|
| 31 |
with_editable: Optional[Path] = None,
|
| 32 |
with_packages: Optional[list[str]] = None,
|
| 33 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 34 |
) -> bool:
|
| 35 |
+
"""Add or update a FastMCP server in Claude's configuration.
|
| 36 |
|
| 37 |
Args:
|
| 38 |
file_spec: Path to the server file, optionally with :object suffix
|
| 39 |
server_name: Name for the server in Claude's config
|
| 40 |
with_editable: Optional directory to install in editable mode
|
| 41 |
with_packages: Optional list of additional packages to install
|
| 42 |
+
env_vars: Optional dictionary of environment variables. These are merged with
|
| 43 |
+
any existing variables, with new values taking precedence.
|
| 44 |
"""
|
| 45 |
config_dir = get_claude_config_path()
|
| 46 |
if not config_dir:
|
|
|
|
| 55 |
if "mcpServers" not in config:
|
| 56 |
config["mcpServers"] = {}
|
| 57 |
|
| 58 |
+
# Always preserve existing env vars and merge with new ones
|
| 59 |
+
if (
|
| 60 |
+
server_name in config["mcpServers"]
|
| 61 |
+
and "env" in config["mcpServers"][server_name]
|
| 62 |
+
):
|
| 63 |
+
existing_env = config["mcpServers"][server_name]["env"]
|
| 64 |
+
if env_vars:
|
| 65 |
+
# New vars take precedence over existing ones
|
| 66 |
+
env_vars = {**existing_env, **env_vars}
|
| 67 |
+
else:
|
| 68 |
+
env_vars = existing_env
|
|
|
|
| 69 |
|
| 70 |
# Build uv run command
|
| 71 |
args = ["run", "--with", "fastmcp"]
|
|
|
|
| 89 |
# Add fastmcp run command
|
| 90 |
args.extend(["fastmcp", "run", file_spec])
|
| 91 |
|
| 92 |
+
server_config = {
|
| 93 |
"command": "uv",
|
| 94 |
"args": args,
|
| 95 |
}
|
| 96 |
|
| 97 |
+
# Add environment variables if specified
|
| 98 |
+
if env_vars:
|
| 99 |
+
server_config["env"] = env_vars
|
| 100 |
+
|
| 101 |
+
config["mcpServers"][server_name] = server_config
|
| 102 |
+
|
| 103 |
config_file.write_text(json.dumps(config, indent=2))
|
| 104 |
logger.info(
|
| 105 |
f"Added server '{server_name}' to Claude config",
|
src/fastmcp/cli/cli.py
CHANGED
|
@@ -5,10 +5,11 @@ import importlib.util
|
|
| 5 |
import subprocess
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
| 8 |
-
from typing import Optional, Tuple
|
| 9 |
|
| 10 |
import typer
|
| 11 |
from typing_extensions import Annotated
|
|
|
|
| 12 |
|
| 13 |
from ..utilities.logging import get_logger
|
| 14 |
from . import claude
|
|
@@ -23,6 +24,17 @@ app = typer.Typer(
|
|
| 23 |
)
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def _build_uv_command(
|
| 27 |
file_spec: str,
|
| 28 |
with_editable: Optional[Path] = None,
|
|
@@ -304,16 +316,32 @@ def install(
|
|
| 304 |
help="Additional packages to install",
|
| 305 |
),
|
| 306 |
] = [],
|
| 307 |
-
|
| 308 |
-
|
| 309 |
typer.Option(
|
| 310 |
-
"--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
"-f",
|
| 312 |
-
help="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
),
|
| 314 |
-
] =
|
| 315 |
) -> None:
|
| 316 |
-
"""Install a FastMCP server in the Claude desktop app.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
file, server_object = _parse_file_path(file_spec)
|
| 318 |
|
| 319 |
logger.debug(
|
|
@@ -324,7 +352,6 @@ def install(
|
|
| 324 |
"server_object": server_object,
|
| 325 |
"with_editable": str(with_editable) if with_editable else None,
|
| 326 |
"with_packages": with_packages,
|
| 327 |
-
"force": force,
|
| 328 |
},
|
| 329 |
)
|
| 330 |
|
|
@@ -345,12 +372,29 @@ def install(
|
|
| 345 |
)
|
| 346 |
name = file.stem
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
if claude.update_claude_config(
|
| 349 |
file_spec,
|
| 350 |
name,
|
| 351 |
with_editable=with_editable,
|
| 352 |
with_packages=with_packages,
|
| 353 |
-
|
| 354 |
):
|
| 355 |
logger.info(f"Successfully installed {name} in Claude app")
|
| 356 |
else:
|
|
|
|
| 5 |
import subprocess
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import Optional, Tuple, Dict
|
| 9 |
|
| 10 |
import typer
|
| 11 |
from typing_extensions import Annotated
|
| 12 |
+
import dotenv
|
| 13 |
|
| 14 |
from ..utilities.logging import get_logger
|
| 15 |
from . import claude
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
| 27 |
+
def _parse_env_var(env_var: str) -> Tuple[str, str]:
|
| 28 |
+
"""Parse environment variable string in format KEY=VALUE."""
|
| 29 |
+
if "=" not in env_var:
|
| 30 |
+
logger.error(
|
| 31 |
+
f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
|
| 32 |
+
)
|
| 33 |
+
sys.exit(1)
|
| 34 |
+
key, value = env_var.split("=", 1)
|
| 35 |
+
return key.strip(), value.strip()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
def _build_uv_command(
|
| 39 |
file_spec: str,
|
| 40 |
with_editable: Optional[Path] = None,
|
|
|
|
| 316 |
help="Additional packages to install",
|
| 317 |
),
|
| 318 |
] = [],
|
| 319 |
+
env_vars: Annotated[
|
| 320 |
+
list[str],
|
| 321 |
typer.Option(
|
| 322 |
+
"--env-var",
|
| 323 |
+
"-e",
|
| 324 |
+
help="Environment variables in KEY=VALUE format",
|
| 325 |
+
),
|
| 326 |
+
] = [],
|
| 327 |
+
env_file: Annotated[
|
| 328 |
+
Optional[Path],
|
| 329 |
+
typer.Option(
|
| 330 |
+
"--env-file",
|
| 331 |
"-f",
|
| 332 |
+
help="Load environment variables from a .env file",
|
| 333 |
+
exists=True,
|
| 334 |
+
file_okay=True,
|
| 335 |
+
dir_okay=False,
|
| 336 |
+
resolve_path=True,
|
| 337 |
),
|
| 338 |
+
] = None,
|
| 339 |
) -> None:
|
| 340 |
+
"""Install a FastMCP server in the Claude desktop app.
|
| 341 |
+
|
| 342 |
+
Environment variables are preserved once added and only updated if new values
|
| 343 |
+
are explicitly provided.
|
| 344 |
+
"""
|
| 345 |
file, server_object = _parse_file_path(file_spec)
|
| 346 |
|
| 347 |
logger.debug(
|
|
|
|
| 352 |
"server_object": server_object,
|
| 353 |
"with_editable": str(with_editable) if with_editable else None,
|
| 354 |
"with_packages": with_packages,
|
|
|
|
| 355 |
},
|
| 356 |
)
|
| 357 |
|
|
|
|
| 372 |
)
|
| 373 |
name = file.stem
|
| 374 |
|
| 375 |
+
# Process environment variables if provided
|
| 376 |
+
env_dict: Optional[Dict[str, str]] = None
|
| 377 |
+
if env_file or env_vars:
|
| 378 |
+
env_dict = {}
|
| 379 |
+
# Load from .env file if specified
|
| 380 |
+
if env_file:
|
| 381 |
+
try:
|
| 382 |
+
env_dict.update(dotenv.dotenv_values(env_file))
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"Failed to load .env file: {e}")
|
| 385 |
+
sys.exit(1)
|
| 386 |
+
|
| 387 |
+
# Add command line environment variables
|
| 388 |
+
for env_var in env_vars:
|
| 389 |
+
key, value = _parse_env_var(env_var)
|
| 390 |
+
env_dict[key] = value
|
| 391 |
+
|
| 392 |
if claude.update_claude_config(
|
| 393 |
file_spec,
|
| 394 |
name,
|
| 395 |
with_editable=with_editable,
|
| 396 |
with_packages=with_packages,
|
| 397 |
+
env_vars=env_dict,
|
| 398 |
):
|
| 399 |
logger.info(f"Successfully installed {name} in Claude app")
|
| 400 |
else:
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the FastMCP CLI."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from unittest.mock import Mock, patch
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from typer.testing import CliRunner
|
| 8 |
+
|
| 9 |
+
from fastmcp.cli.cli import app, _parse_env_var
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def mock_config(tmp_path):
|
| 14 |
+
"""Create a mock Claude config file."""
|
| 15 |
+
config = {"mcpServers": {}}
|
| 16 |
+
config_file = tmp_path / "claude_desktop_config.json"
|
| 17 |
+
config_file.write_text(json.dumps(config))
|
| 18 |
+
return config_file
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def mock_server_file(tmp_path):
|
| 23 |
+
"""Create a mock server file."""
|
| 24 |
+
server_file = tmp_path / "server.py"
|
| 25 |
+
server_file.write_text(
|
| 26 |
+
"from fastmcp import Server\n" "server = Server(name='test')\n"
|
| 27 |
+
)
|
| 28 |
+
return server_file
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def mock_env_file(tmp_path):
|
| 33 |
+
"""Create a mock .env file."""
|
| 34 |
+
env_file = tmp_path / ".env"
|
| 35 |
+
env_file.write_text("FOO=bar\nBAZ=123")
|
| 36 |
+
return env_file
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_parse_env_var():
|
| 40 |
+
"""Test parsing environment variables."""
|
| 41 |
+
assert _parse_env_var("FOO=bar") == ("FOO", "bar")
|
| 42 |
+
assert _parse_env_var("FOO=") == ("FOO", "")
|
| 43 |
+
assert _parse_env_var("FOO=bar baz") == ("FOO", "bar baz")
|
| 44 |
+
assert _parse_env_var("FOO = bar ") == ("FOO", "bar")
|
| 45 |
+
|
| 46 |
+
with pytest.raises(SystemExit):
|
| 47 |
+
_parse_env_var("invalid")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.mark.parametrize(
|
| 51 |
+
"args,expected_env",
|
| 52 |
+
[
|
| 53 |
+
# Basic env var
|
| 54 |
+
(
|
| 55 |
+
["--env-var", "FOO=bar"],
|
| 56 |
+
{"FOO": "bar"},
|
| 57 |
+
),
|
| 58 |
+
# Multiple env vars
|
| 59 |
+
(
|
| 60 |
+
["--env-var", "FOO=bar", "--env-var", "BAZ=123"],
|
| 61 |
+
{"FOO": "bar", "BAZ": "123"},
|
| 62 |
+
),
|
| 63 |
+
# Env var with spaces
|
| 64 |
+
(
|
| 65 |
+
["--env-var", "FOO=bar baz"],
|
| 66 |
+
{"FOO": "bar baz"},
|
| 67 |
+
),
|
| 68 |
+
],
|
| 69 |
+
)
|
| 70 |
+
def test_install_with_env_vars(mock_config, mock_server_file, args, expected_env):
|
| 71 |
+
"""Test installing with environment variables."""
|
| 72 |
+
runner = CliRunner()
|
| 73 |
+
|
| 74 |
+
with (
|
| 75 |
+
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
|
| 76 |
+
patch("fastmcp.cli.cli._import_server") as mock_import,
|
| 77 |
+
):
|
| 78 |
+
mock_config_path.return_value = mock_config.parent
|
| 79 |
+
mock_server = Mock()
|
| 80 |
+
mock_server.name = "test" # Set name as an attribute
|
| 81 |
+
mock_import.return_value = mock_server
|
| 82 |
+
|
| 83 |
+
result = runner.invoke(
|
| 84 |
+
app,
|
| 85 |
+
["install", str(mock_server_file)] + args,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
assert result.exit_code == 0
|
| 89 |
+
|
| 90 |
+
# Read the config file and check env vars
|
| 91 |
+
config = json.loads(mock_config.read_text())
|
| 92 |
+
assert "mcpServers" in config
|
| 93 |
+
assert len(config["mcpServers"]) == 1
|
| 94 |
+
server = next(iter(config["mcpServers"].values()))
|
| 95 |
+
assert server["env"] == expected_env
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_install_with_env_file(mock_config, mock_server_file, mock_env_file):
|
| 99 |
+
"""Test installing with environment variables from a file."""
|
| 100 |
+
runner = CliRunner()
|
| 101 |
+
|
| 102 |
+
with (
|
| 103 |
+
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
|
| 104 |
+
patch("fastmcp.cli.cli._import_server") as mock_import,
|
| 105 |
+
):
|
| 106 |
+
mock_config_path.return_value = mock_config.parent
|
| 107 |
+
mock_server = Mock()
|
| 108 |
+
mock_server.name = "test" # Set name as an attribute
|
| 109 |
+
mock_import.return_value = mock_server
|
| 110 |
+
|
| 111 |
+
result = runner.invoke(
|
| 112 |
+
app,
|
| 113 |
+
["install", str(mock_server_file), "--env-file", str(mock_env_file)],
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
assert result.exit_code == 0
|
| 117 |
+
|
| 118 |
+
# Read the config file and check env vars
|
| 119 |
+
config = json.loads(mock_config.read_text())
|
| 120 |
+
assert "mcpServers" in config
|
| 121 |
+
assert len(config["mcpServers"]) == 1
|
| 122 |
+
server = next(iter(config["mcpServers"].values()))
|
| 123 |
+
assert server["env"] == {"FOO": "bar", "BAZ": "123"}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_install_preserves_existing_env_vars(mock_config, mock_server_file):
|
| 127 |
+
"""Test that installing preserves existing environment variables."""
|
| 128 |
+
# Set up initial config with env vars
|
| 129 |
+
config = {
|
| 130 |
+
"mcpServers": {
|
| 131 |
+
"test": {
|
| 132 |
+
"command": "uv",
|
| 133 |
+
"args": [
|
| 134 |
+
"run",
|
| 135 |
+
"--with",
|
| 136 |
+
"fastmcp",
|
| 137 |
+
"fastmcp",
|
| 138 |
+
"run",
|
| 139 |
+
str(mock_server_file),
|
| 140 |
+
],
|
| 141 |
+
"env": {"FOO": "bar", "BAZ": "123"},
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
mock_config.write_text(json.dumps(config))
|
| 146 |
+
|
| 147 |
+
runner = CliRunner()
|
| 148 |
+
|
| 149 |
+
with (
|
| 150 |
+
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
|
| 151 |
+
patch("fastmcp.cli.cli._import_server") as mock_import,
|
| 152 |
+
):
|
| 153 |
+
mock_config_path.return_value = mock_config.parent
|
| 154 |
+
mock_server = Mock()
|
| 155 |
+
mock_server.name = "test" # Set name as an attribute
|
| 156 |
+
mock_import.return_value = mock_server
|
| 157 |
+
|
| 158 |
+
# Install with a new env var
|
| 159 |
+
result = runner.invoke(
|
| 160 |
+
app,
|
| 161 |
+
["install", str(mock_server_file), "--env-var", "NEW=value"],
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
assert result.exit_code == 0
|
| 165 |
+
|
| 166 |
+
# Read the config file and check env vars are preserved
|
| 167 |
+
config = json.loads(mock_config.read_text())
|
| 168 |
+
server = next(iter(config["mcpServers"].values()))
|
| 169 |
+
assert server["env"] == {"FOO": "bar", "BAZ": "123", "NEW": "value"}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_install_updates_existing_env_vars(mock_config, mock_server_file):
|
| 173 |
+
"""Test that installing updates existing environment variables."""
|
| 174 |
+
# Set up initial config with env vars
|
| 175 |
+
config = {
|
| 176 |
+
"mcpServers": {
|
| 177 |
+
"test": {
|
| 178 |
+
"command": "uv",
|
| 179 |
+
"args": [
|
| 180 |
+
"run",
|
| 181 |
+
"--with",
|
| 182 |
+
"fastmcp",
|
| 183 |
+
"fastmcp",
|
| 184 |
+
"run",
|
| 185 |
+
str(mock_server_file),
|
| 186 |
+
],
|
| 187 |
+
"env": {"FOO": "bar", "BAZ": "123"},
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
mock_config.write_text(json.dumps(config))
|
| 192 |
+
|
| 193 |
+
runner = CliRunner()
|
| 194 |
+
|
| 195 |
+
with (
|
| 196 |
+
patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path,
|
| 197 |
+
patch("fastmcp.cli.cli._import_server") as mock_import,
|
| 198 |
+
):
|
| 199 |
+
mock_config_path.return_value = mock_config.parent
|
| 200 |
+
mock_server = Mock()
|
| 201 |
+
mock_server.name = "test" # Set name as an attribute
|
| 202 |
+
mock_import.return_value = mock_server
|
| 203 |
+
|
| 204 |
+
# Update an existing env var
|
| 205 |
+
result = runner.invoke(
|
| 206 |
+
app,
|
| 207 |
+
["install", str(mock_server_file), "--env-var", "FOO=newvalue"],
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
assert result.exit_code == 0
|
| 211 |
+
|
| 212 |
+
# Read the config file and check env var was updated
|
| 213 |
+
config = json.loads(mock_config.read_text())
|
| 214 |
+
server = next(iter(config["mcpServers"].values()))
|
| 215 |
+
assert server["env"] == {"FOO": "newvalue", "BAZ": "123"}
|
uv.lock
CHANGED
|
@@ -228,13 +228,14 @@ wheels = [
|
|
| 228 |
|
| 229 |
[[package]]
|
| 230 |
name = "fastmcp"
|
| 231 |
-
version = "0.3.
|
| 232 |
source = { editable = "." }
|
| 233 |
dependencies = [
|
| 234 |
{ name = "httpx" },
|
| 235 |
{ name = "mcp" },
|
| 236 |
{ name = "pydantic" },
|
| 237 |
{ name = "pydantic-settings" },
|
|
|
|
| 238 |
{ name = "typer" },
|
| 239 |
]
|
| 240 |
|
|
@@ -263,6 +264,7 @@ requires-dist = [
|
|
| 263 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
|
| 264 |
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
|
| 265 |
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.1" },
|
|
|
|
| 266 |
{ name = "ruff", marker = "extra == 'dev'" },
|
| 267 |
{ name = "typer", specifier = ">=0.9.0" },
|
| 268 |
]
|
|
|
|
| 228 |
|
| 229 |
[[package]]
|
| 230 |
name = "fastmcp"
|
| 231 |
+
version = "0.3.2.dev0+g5656200.d20241201"
|
| 232 |
source = { editable = "." }
|
| 233 |
dependencies = [
|
| 234 |
{ name = "httpx" },
|
| 235 |
{ name = "mcp" },
|
| 236 |
{ name = "pydantic" },
|
| 237 |
{ name = "pydantic-settings" },
|
| 238 |
+
{ name = "python-dotenv" },
|
| 239 |
{ name = "typer" },
|
| 240 |
]
|
| 241 |
|
|
|
|
| 264 |
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
|
| 265 |
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
|
| 266 |
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.1" },
|
| 267 |
+
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
| 268 |
{ name = "ruff", marker = "extra == 'dev'" },
|
| 269 |
{ name = "typer", specifier = ">=0.9.0" },
|
| 270 |
]
|