arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/settings.py
Eric Gustin 4a737b9710
Improve .env discovery (#737)
Resolves TOO-201

Documentation PR for this is here:
https://github.com/ArcadeAI/docs/pull/626


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how environment variables/secrets are discovered and loaded,
which can subtly alter runtime behavior depending on directory structure
and existing env vars; bounded traversal and added tests reduce but
don’t eliminate this risk.
> 
> **Overview**
> **Improves `.env` discovery across the MCP server and CLI.** Adds
`find_env_file()` (bounded by the nearest `pyproject.toml` by default)
and switches settings loading, `arcade deploy`, `arcade configure` stdio
env injection, and provider API-key resolution to use it.
> 
> Updates dev reload to also watch the discovered `.env` even when it
lives outside the current working directory, adjusts `deploy --secrets
all` to only run when a `.env` was found, and moves the minimal
scaffold’s `.env.example` to the project root with updated
tests/integration checks. Version bumps align examples and top-level
deps with `arcade-mcp-server` `1.17.4` and `arcade-mcp` `1.11.2`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
40cff1738c14674ce01f09fd325ece9c874cd072. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:20:28 -08:00

416 lines
13 KiB
Python

"""
MCP Settings Management
Provides Pydantic-based settings with validation and environment variable support.
"""
import os
from pathlib import Path
from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
def _find_project_root(start_dir: Path) -> Path | None:
"""Find the nearest ancestor directory containing pyproject.toml.
This is used as a default boundary for upward directory traversal
to prevent accidentally loading files from unrelated parent directories.
Args:
start_dir: Directory to start searching from (must be resolved).
Returns:
Path to the project root directory, or None if no pyproject.toml is found.
"""
current = start_dir
while True:
if (current / "pyproject.toml").is_file():
return current
parent = current.parent
if parent == current:
return None
current = parent
def find_env_file(
start_dir: Path | None = None,
stop_at: Path | None = None,
filename: str = ".env",
) -> Path | None:
"""Find a .env file by traversing upward through parent directories.
Starts at the specified directory (or current working directory) and
traverses upward through parent directories until a .env file is found
or a boundary is reached.
By default, traversal stops at the nearest ancestor directory containing
``pyproject.toml`` (the project root). This prevents accidentally loading
an unrelated ``.env`` file from ``~/`` or other parent directories.
Pass an explicit ``stop_at`` to override this behavior.
Args:
start_dir: Directory to start searching from. Defaults to current working directory.
stop_at: Directory to stop traversal at (inclusive). If specified, the search
will not continue past this directory. The stop_at directory itself
is still checked for the .env file. When not specified, the nearest
ancestor containing ``pyproject.toml`` is used as the boundary.
filename: Name of the env file to find. Defaults to ".env".
Returns:
Path to the .env file if found, None otherwise.
Example:
# Find .env starting from current directory (bounded by pyproject.toml)
env_path = find_env_file()
# Find .env starting from a specific directory
env_path = find_env_file(start_dir=Path("/path/to/project/src"))
# Find .env but don't search above a specific directory
env_path = find_env_file(stop_at=Path("/path/to/project"))
"""
current = start_dir or Path.cwd()
current = current.resolve()
stop_at = stop_at.resolve() if stop_at is not None else _find_project_root(current)
while True:
env_path = current / filename
if env_path.is_file():
return env_path
if stop_at is not None and current == stop_at:
return None
parent = current.parent
if parent == current:
# We've reached the filesystem root
return None
current = parent
class NotificationSettings(BaseSettings):
"""Notification-related settings."""
rate_limit_per_minute: int = Field(
default=60,
description="Maximum notifications per minute per client",
ge=1,
le=1000,
)
default_debounce_ms: int = Field(
default=100,
description="Default debounce time in milliseconds",
ge=0,
le=10000,
)
max_queued_notifications: int = Field(
default=1000,
description="Maximum queued notifications per client",
ge=10,
le=10000,
)
model_config = {"env_prefix": "MCP_NOTIFICATION_"}
class TransportSettings(BaseSettings):
"""Transport-related settings."""
session_timeout_seconds: int = Field(
default=300,
description="Session timeout in seconds",
ge=30,
le=3600,
)
cleanup_interval_seconds: int = Field(
default=10,
description="Cleanup interval in seconds",
ge=1,
le=60,
)
max_sessions: int = Field(
default=1000,
description="Maximum concurrent sessions",
ge=1,
le=10000,
)
max_queue_size: int = Field(
default=1000,
description="Maximum queue size per session",
ge=10,
le=10000,
)
model_config = {"env_prefix": "MCP_TRANSPORT_"}
class ServerSettings(BaseSettings):
"""Server-related settings."""
name: str = Field(
default="ArcadeMCP",
description="Server name",
)
version: str = Field(
default="0.1.0dev",
description="Server version",
)
title: str | None = Field(
default="ArcadeMCP",
description="Server title for display",
)
instructions: str | None = Field(
default=(
"ArcadeMCP provides access to a wide range of tools and toolkits."
"Use 'tools/list' to see available tools and 'tools/call' to execute them."
),
description="Server instructions for clients",
)
model_config = {"env_prefix": "MCP_SERVER_"}
class ResourceServerSettings(BaseSettings):
"""Settings for ResourceServer configuration via environment variables."""
canonical_url: str | None = Field(
default=None,
description="Canonical URL of this MCP server (e.g., https://mcp.example.com/mcp)",
)
authorization_servers: list[dict[str, Any]] | None = Field(
default=None,
description="JSON array of authorization server entries."
'Example: \'[{"authorization_server_url":"https://auth.example.com","issuer":"https://auth.example.com","jwks_uri":"https://auth.example.com/oauth2/jwks","algorithm":"RS256"}]\'',
)
@field_validator("authorization_servers", mode="before")
@classmethod
def parse_authorization_servers(cls, v: Any) -> list[dict[str, Any]] | None:
"""Parse JSON array from environment variable."""
if v is None:
return None
if isinstance(v, str):
import json
try:
parsed = json.loads(v)
if not isinstance(parsed, list):
raise TypeError("authorization_servers must be a JSON array")
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in authorization_servers: {e}") from e
else:
return parsed
if isinstance(v, list):
return v
return None
def to_authorization_server_entries(self) -> list[Any]:
"""Convert settings to list of AuthorizationServerEntry objects."""
if not self.authorization_servers:
return []
from arcade_mcp_server.resource_server import (
AccessTokenValidationOptions,
AuthorizationServerEntry,
)
return [
AuthorizationServerEntry(
authorization_server_url=config["authorization_server_url"],
issuer=config["issuer"],
jwks_uri=config["jwks_uri"],
algorithm=config.get("algorithm", "RS256"),
expected_audiences=config.get("expected_audiences"),
validation_options=AccessTokenValidationOptions(
verify_exp=config.get("validation_options", {}).get("verify_exp", True),
verify_iat=config.get("validation_options", {}).get("verify_iat", True),
verify_iss=config.get("validation_options", {}).get("verify_iss", True),
verify_nbf=config.get("validation_options", {}).get("verify_nbf", True),
leeway=config.get("validation_options", {}).get("leeway", 0),
),
)
for config in self.authorization_servers
]
model_config = {"env_prefix": "MCP_RESOURCE_SERVER_"}
class MiddlewareSettings(BaseSettings):
"""Middleware-related settings."""
enable_logging: bool = Field(
default=True,
description="Enable logging middleware",
)
log_level: str = Field(
default="INFO",
description="Log level",
)
enable_error_handling: bool = Field(
default=True,
description="Enable error handling middleware",
)
mask_error_details: bool = Field(
default=False,
description="Mask error details in production",
)
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Validate log level."""
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
v = v.upper()
if v not in valid_levels:
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
return v
model_config = {"env_prefix": "MCP_MIDDLEWARE_"}
class ArcadeSettings(BaseSettings):
"""Arcade-specific settings."""
api_key: str | None = Field(
default=None,
description="Arcade API key",
)
api_url: str = Field(
default="https://api.arcade.dev",
description="Arcade API URL",
)
auth_disabled: bool = Field(
default=False,
description="Disable authentication",
)
server_secret: str | None = Field(
default=None,
description="Server secret for worker endpoints (required to enable worker routes)",
validation_alias="ARCADE_WORKER_SECRET",
)
environment: str = Field(
default="dev",
description="Environment (dev or prod.)",
)
user_id: str | None = Field(
default=None,
description="User ID for Arcade environment",
)
model_config = {"env_prefix": "ARCADE_"}
class ToolEnvironmentSettings(BaseSettings):
"""Tool environment settings.
Every environment variable that is not prefixed
with one of the prefixes for the other settings
will be added to the tool environment as an
available tool secret in the ToolContext
"""
tool_environment: dict[str, Any] = Field(
default_factory=dict,
description="Tool environment",
)
def model_post_init(self, __context: Any) -> None:
"""Populate tool_environment from process env if not provided."""
if not self.tool_environment:
excluded_prefixes = ("MCP_", "_")
self.tool_environment = {
key: value
for key, value in os.environ.items()
if not any(key.startswith(prefix) for prefix in excluded_prefixes)
}
model_config = {
"env_prefix": "",
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
"extra": "allow",
}
class MCPSettings(BaseSettings):
"""Main MCP settings container."""
# Sub-settings
notification: NotificationSettings = Field(
default_factory=NotificationSettings,
description="Notification settings",
)
transport: TransportSettings = Field(
default_factory=TransportSettings,
description="Transport settings",
)
server: ServerSettings = Field(
default_factory=ServerSettings,
description="Server settings",
)
resource_server: ResourceServerSettings = Field(
default_factory=ResourceServerSettings,
description="Server authentication settings",
)
middleware: MiddlewareSettings = Field(
default_factory=MiddlewareSettings,
description="Middleware settings",
)
arcade: ArcadeSettings = Field(
default_factory=ArcadeSettings,
description="Arcade integration settings",
)
tool_environment: ToolEnvironmentSettings = Field(
default_factory=ToolEnvironmentSettings,
description="Tool environment settings",
)
# Global settings
debug: bool = Field(
default=False,
description="Enable debug mode",
)
model_config = {
"env_prefix": "MCP_",
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
"extra": "allow",
}
@classmethod
def from_env(cls) -> "MCPSettings":
"""Create settings from environment variables.
Automatically discovers and loads .env file by traversing upward from
the current directory through parent directories until a .env file is
found or the filesystem root is reached.
The .env file is loaded with override=False, meaning existing
environment variables take precedence. Multiple calls are safe.
"""
from dotenv import load_dotenv
env_path = find_env_file()
if env_path is not None:
load_dotenv(env_path, override=False)
return cls()
def tool_secrets(self) -> dict[str, Any]:
"""Get tool secrets."""
return self.tool_environment.tool_environment
def to_dict(self) -> dict[str, Any]:
"""Convert settings to dictionary."""
return self.model_dump(exclude_unset=True)
# Global settings instance
settings = MCPSettings.from_env()