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>
This commit is contained in:
Eric Gustin 2026-02-25 23:20:28 -08:00 committed by GitHub
parent c50699d5e6
commit 4a737b9710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 292 additions and 44 deletions

View file

@ -4,13 +4,13 @@ version = "0.1.0"
description = "MCP Server created with Arcade.dev" description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"arcade-mcp-server>=1.12.0,<2.0.0", "arcade-mcp-server>=1.17.4,<2.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"arcade-mcp[all]>=1.5.2,<2.0.0", "arcade-mcp[all]>=1.11.2,<2.0.0",
"pytest>=7.0.0", "pytest>=7.0.0",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"mypy>=1.0.0", "mypy>=1.0.0",

View file

@ -4,13 +4,13 @@ version = "0.1.0"
description = "MCP Server created with Arcade.dev" description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0", "arcade-mcp-server>=1.17.4,<2.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0", "arcade-mcp[all]>=1.11.2,<2.0.0",
"pytest>=7.0.0", "pytest>=7.0.0",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"mypy>=1.0.0", "mypy>=1.0.0",

View file

@ -4,13 +4,13 @@ version = "0.1.0"
description = "MCP Server created with Arcade.dev" description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0", "arcade-mcp-server>=1.17.4,<2.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0", "arcade-mcp[all]>=1.11.2,<2.0.0",
"pytest>=7.0.0", "pytest>=7.0.0",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"mypy>=1.0.0", "mypy>=1.0.0",

View file

@ -10,6 +10,7 @@ import subprocess
from pathlib import Path from pathlib import Path
import typer import typer
from arcade_mcp_server.settings import find_env_file
from dotenv import dotenv_values from dotenv import dotenv_values
from arcade_cli.console import console from arcade_cli.console import console
@ -204,10 +205,16 @@ def is_uv_installed() -> bool:
def get_tool_secrets() -> dict: def get_tool_secrets() -> dict:
"""Only useful for stdio servers, because HTTP servers load in envvars at runtime""" """Get tool secrets from .env file for stdio servers.
# TODO: Allow for a custom .env file to be used
env_path = Path.cwd() / ".env" Discovers .env file by traversing upward from the current directory
if env_path.exists(): through parent directories until a .env file is found.
Returns:
Dictionary of environment variables from the .env file, or empty dict if not found.
"""
env_path = find_env_file()
if env_path is not None:
return dotenv_values(env_path) return dotenv_values(env_path)
return {} return {}

View file

@ -16,6 +16,7 @@ from arcade_core.subprocess_utils import (
get_windows_no_window_creationflags, get_windows_no_window_creationflags,
graceful_terminate_process, graceful_terminate_process,
) )
from arcade_mcp_server.settings import find_env_file
from dotenv import load_dotenv from dotenv import load_dotenv
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from rich.columns import Columns from rich.columns import Columns
@ -817,14 +818,14 @@ def deploy_server_logic(
) )
console.print(f"✓ Entrypoint file found at {entrypoint_path}", style="green") console.print(f"✓ Entrypoint file found at {entrypoint_path}", style="green")
# Step 3: Load .env file from current directory if it exists # Step 3: Load .env file if it exists (searches upward through parent directories)
console.print("\nLoading .env file from current directory if it exists...", style="dim") console.print("\nSearching for .env file...", style="dim")
env_path = current_dir / ".env" env_path = find_env_file()
if env_path.exists(): if env_path is not None:
load_dotenv(env_path, override=False) load_dotenv(env_path, override=False)
console.print(f"✓ Loaded environment from {env_path}", style="green") console.print(f"✓ Loaded environment from {env_path}", style="green")
else: else:
console.print(f"[!] No .env file found at {env_path}", style="yellow") console.print("[!] No .env file found in current or parent directories", style="yellow")
# Step 4: Verify server and extract metadata (or skip if --skip-validate) # Step 4: Verify server and extract metadata (or skip if --skip-validate)
required_secrets_from_validation: set[str] = set() required_secrets_from_validation: set[str] = set()
@ -860,7 +861,7 @@ def deploy_server_logic(
if secrets == "skip": if secrets == "skip":
console.print("\n[!] Skipping secret upload (--secrets skip)", style="yellow") console.print("\n[!] Skipping secret upload (--secrets skip)", style="yellow")
elif secrets == "all": elif secrets == "all" and env_path is not None:
console.print("\nUploading ALL secrets from .env file...", style="dim") console.print("\nUploading ALL secrets from .env file...", style="dim")
secrets_to_upsert = set(load_env_file(str(env_path)).keys()) secrets_to_upsert = set(load_env_file(str(env_path)).keys())
if secrets_to_upsert: if secrets_to_upsert:

View file

@ -0,0 +1,13 @@
# Environment variables for {{ toolkit_name }} MCP server
#
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# The .env file will be automatically discovered when running your server,
# even from subdirectories like src/{{ toolkit_name }}/.
#
# IMPORTANT: Never commit your .env file to version control!
# Example secret used by the whisper_secret tool
# Replace with your actual secret value
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"

View file

@ -1 +0,0 @@
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"

View file

@ -26,6 +26,7 @@ from arcade_core.discovery import (
from arcade_core.errors import ToolkitLoadError from arcade_core.errors import ToolkitLoadError
from arcade_core.network.org_transport import build_org_scoped_http_client from arcade_core.network.org_transport import build_org_scoped_http_client
from arcade_core.schema import ToolDefinition from arcade_core.schema import ToolDefinition
from arcade_mcp_server.settings import find_env_file
from arcadepy import ( from arcadepy import (
NOT_GIVEN, NOT_GIVEN,
APIConnectionError, APIConnectionError,
@ -1123,9 +1124,9 @@ def resolve_provider_api_key(provider: Provider, provider_api_key: str | None =
if api_key: if api_key:
return api_key return api_key
# Then check .env file in current working directory # Then check .env file by traversing upward through parent directories
env_file_path = Path.cwd() / ".env" env_file_path = find_env_file()
if env_file_path.exists(): if env_file_path is not None:
load_dotenv(env_file_path, override=False) load_dotenv(env_file_path, override=False)
api_key = os.getenv(env_var_name) api_key = os.getenv(env_var_name)
if api_key: if api_key:

View file

@ -31,7 +31,7 @@ from arcade_mcp_server.exceptions import ServerError
from arcade_mcp_server.logging_utils import intercept_standard_logging from arcade_mcp_server.logging_utils import intercept_standard_logging
from arcade_mcp_server.resource_server.base import ResourceServerValidator from arcade_mcp_server.resource_server.base import ResourceServerValidator
from arcade_mcp_server.server import MCPServer from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings, ServerSettings from arcade_mcp_server.settings import MCPSettings, ServerSettings, find_env_file
from arcade_mcp_server.types import Prompt, PromptMessage, Resource from arcade_mcp_server.types import Prompt, PromptMessage, Resource
from arcade_mcp_server.usage import ServerTracker from arcade_mcp_server.usage import ServerTracker
from arcade_mcp_server.worker import create_arcade_mcp, serve_with_force_quit from arcade_mcp_server.worker import create_arcade_mcp, serve_with_force_quit
@ -367,7 +367,7 @@ class MCPApp:
This method runs as the parent process that watches for file changes This method runs as the parent process that watches for file changes
and spawns/restarts child processes to run the actual server. and spawns/restarts child processes to run the actual server.
""" """
env_file_path = Path.cwd() / ".env" env_file_path = find_env_file()
def start_server_process() -> subprocess.Popen: def start_server_process() -> subprocess.Popen:
"""Start a child process running the server.""" """Start a child process running the server."""
@ -414,9 +414,17 @@ class MCPApp:
try: try:
def watch_filter(change: Any, path: str) -> bool: def watch_filter(change: Any, path: str) -> bool:
return path.endswith(".py") or (Path(path) == env_file_path) # Watch Python files and the .env file (if one was found)
return path.endswith(".py") or (
env_file_path is not None and Path(path) == env_file_path
)
for changes in watch(".", watch_filter=watch_filter): # Watch current directory, plus the .env file if it's outside cwd
paths_to_watch: list[str] = ["."]
if env_file_path is not None:
paths_to_watch.append(str(env_file_path))
for changes in watch(*paths_to_watch, watch_filter=watch_filter):
logger.info(f"Detected changes in {len(changes)} file(s), restarting server...") logger.info(f"Detected changes in {len(changes)} file(s), restarting server...")
shutdown_server_process(process, reason="reload") shutdown_server_process(process, reason="reload")
process = start_server_process() process = start_server_process()

View file

@ -12,6 +12,86 @@ from pydantic import Field, field_validator
from pydantic_settings import BaseSettings 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): class NotificationSettings(BaseSettings):
"""Notification-related settings.""" """Notification-related settings."""
@ -308,16 +388,17 @@ class MCPSettings(BaseSettings):
def from_env(cls) -> "MCPSettings": def from_env(cls) -> "MCPSettings":
"""Create settings from environment variables. """Create settings from environment variables.
Automatically loads .env file from current directory if it exists, Automatically discovers and loads .env file by traversing upward from
then creates settings from the combined environment. 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 The .env file is loaded with override=False, meaning existing
environment variables take precedence. Multiple calls are safe environment variables take precedence. Multiple calls are safe.
""" """
from dotenv import load_dotenv from dotenv import load_dotenv
env_path = Path.cwd() / ".env" env_path = find_env_file()
if env_path.exists(): if env_path is not None:
load_dotenv(env_path, override=False) load_dotenv(env_path, override=False)
return cls() return cls()

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "arcade-mcp-server" name = "arcade-mcp-server"
version = "1.17.3" version = "1.17.4"
description = "Model Context Protocol (MCP) server framework for Arcade.dev" description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md" readme = "README.md"
authors = [{ name = "Arcade.dev" }] authors = [{ name = "Arcade.dev" }]

View file

@ -0,0 +1,111 @@
"""Tests for find_env_file() upward directory traversal."""
from pathlib import Path
import pytest
from arcade_mcp_server.settings import find_env_file
class TestFindEnvFile:
"""Test the find_env_file() utility function."""
def test_finds_env_in_current_directory(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should find .env file in cwd."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_VAR=value")
monkeypatch.chdir(tmp_path)
assert find_env_file() == env_file
def test_finds_env_in_parent_directory(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should traverse upward to find .env in parent (no pyproject.toml boundary)."""
subdir = tmp_path / "a" / "b" / "c"
subdir.mkdir(parents=True)
env_file = tmp_path / ".env"
env_file.write_text("TEST_VAR=value")
monkeypatch.chdir(subdir)
assert find_env_file() == env_file
def test_prefers_closest_env_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should find closest .env when multiple exist."""
subdir = tmp_path / "subdir"
subdir.mkdir()
(tmp_path / ".env").write_text("ROOT=1")
closer_env = subdir / ".env"
closer_env.write_text("CLOSER=1")
monkeypatch.chdir(subdir)
assert find_env_file() == closer_env
def test_returns_none_when_not_found(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should return None when no .env exists."""
subdir = tmp_path / "subdir"
subdir.mkdir()
monkeypatch.chdir(subdir)
assert find_env_file() is None
def test_stop_at_limits_traversal(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Explicit stop_at should prevent traversing past specified directory."""
project = tmp_path / "project" / "src"
project.mkdir(parents=True)
(tmp_path / ".env").write_text("OUTSIDE=1")
monkeypatch.chdir(project)
assert find_env_file(stop_at=tmp_path / "project") is None
def test_stops_at_pyproject_toml_boundary(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should not traverse past directory containing pyproject.toml."""
project_root = tmp_path / "project"
project_root.mkdir()
(project_root / "pyproject.toml").write_text("[project]\nname = 'test'")
src = project_root / "src"
src.mkdir()
# .env is above the project root — should NOT be found
(tmp_path / ".env").write_text("OUTSIDE=1")
monkeypatch.chdir(src)
assert find_env_file() is None
def test_finds_env_at_pyproject_toml_level(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should find .env at the same level as pyproject.toml."""
project_root = tmp_path / "project"
project_root.mkdir()
(project_root / "pyproject.toml").write_text("[project]\nname = 'test'")
env_file = project_root / ".env"
env_file.write_text("SECRET=value")
src = project_root / "src"
src.mkdir()
monkeypatch.chdir(src)
assert find_env_file() == env_file
def test_finds_env_below_pyproject_toml(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should find .env in a subdirectory within the project."""
project_root = tmp_path / "project"
project_root.mkdir()
(project_root / "pyproject.toml").write_text("[project]\nname = 'test'")
src = project_root / "src"
src.mkdir()
env_file = src / ".env"
env_file.write_text("SECRET=value")
monkeypatch.chdir(src)
assert find_env_file() == env_file

View file

@ -1,3 +1,5 @@
"""Tests for get_tool_secrets() in arcade configure."""
import json import json
import sys import sys
import types import types
@ -10,6 +12,7 @@ from arcade_cli.configure import (
_resolve_windows_appdata, _resolve_windows_appdata,
_warn_overwrite, _warn_overwrite,
configure_client, configure_client,
get_tool_secrets,
) )
@ -30,6 +33,28 @@ def _assert_stdio_entry(entry: dict) -> None:
assert "env" in entry assert "env" in entry
def test_get_tool_secrets_loads_from_env_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should load secrets from .env file."""
env_file = tmp_path / ".env"
env_file.write_text("SECRET_ONE=value1\nSECRET_TWO=value2")
monkeypatch.chdir(tmp_path)
secrets = get_tool_secrets()
assert secrets.get("SECRET_ONE") == "value1"
assert secrets.get("SECRET_TWO") == "value2"
def test_get_tool_secrets_returns_empty_when_no_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should return empty dict when no .env exists."""
monkeypatch.chdir(tmp_path)
assert get_tool_secrets() == {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _format_path_for_display() # _format_path_for_display()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -15,7 +15,7 @@ def test_create_new_toolkit_minimal_with_spaces(tmp_path: Path) -> None:
server_root = output_dir / "my_server" server_root = output_dir / "my_server"
assert (server_root / "pyproject.toml").is_file() assert (server_root / "pyproject.toml").is_file()
assert (server_root / "src" / "my_server" / "server.py").is_file() assert (server_root / "src" / "my_server" / "server.py").is_file()
assert (server_root / "src" / "my_server" / ".env.example").is_file() assert (server_root / ".env.example").is_file()
def test_create_new_toolkit_minimal_prints_next_steps(tmp_path: Path) -> None: def test_create_new_toolkit_minimal_prints_next_steps(tmp_path: Path) -> None:

View file

@ -41,21 +41,23 @@ def pytest_collection_modifyitems(config, items):
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def disable_usage_tracking(): def isolate_environment():
"""Disable CLI usage tracking for all tests. """Isolate environment variables for each test.
This prevents test runs from sending analytics events to PostHog. This fixture captures the entire environment before a test and restores it
The fixture is autouse=True so it applies automatically to every test. after. This ensures that environment variables set by load_dotenv() or any
other mechanism during tests don't leak into subsequent tests.
This also disables CLI usage tracking to prevent test runs from sending
analytics events to PostHog.
""" """
original_value = os.environ.get("ARCADE_USAGE_TRACKING") original_env = os.environ.copy()
# Disable tracking # Disable tracking
os.environ["ARCADE_USAGE_TRACKING"] = "0" os.environ["ARCADE_USAGE_TRACKING"] = "0"
yield yield
# Restore original value after test # Restore the original environment
if original_value is None: os.environ.clear()
os.environ.pop("ARCADE_USAGE_TRACKING", None) os.environ.update(original_env)
else:
os.environ["ARCADE_USAGE_TRACKING"] = original_value

View file

@ -1,6 +1,6 @@
[project] [project]
name = "arcade-mcp" name = "arcade-mcp"
version = "1.11.1" version = "1.11.2"
description = "Arcade.dev - Tool Calling platform for Agents" description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
@ -19,7 +19,7 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
# CLI dependencies # CLI dependencies
"arcade-mcp-server>=1.17.2,<2.0.0", "arcade-mcp-server>=1.17.4,<2.0.0",
"arcade-core>=4.4.2,<5.0.0", "arcade-core>=4.4.2,<5.0.0",
"typer==0.10.0", "typer==0.10.0",
"rich>=14.0.0,<15.0.0", "rich>=14.0.0,<15.0.0",

View file

@ -255,7 +255,7 @@ def _run_scaffold_and_protocol_smoke(repo_root: Path) -> None:
generated_root = scaffold_dir / "my_server" generated_root = scaffold_dir / "my_server"
_ensure_exists(generated_root / "pyproject.toml") _ensure_exists(generated_root / "pyproject.toml")
_ensure_exists(generated_root / "src" / "my_server" / "server.py") _ensure_exists(generated_root / "src" / "my_server" / "server.py")
_ensure_exists(generated_root / "src" / "my_server" / ".env.example") _ensure_exists(generated_root / ".env.example")
generated_pyproject = generated_root / "pyproject.toml" generated_pyproject = generated_root / "pyproject.toml"
_add_local_uv_sources(generated_pyproject, repo_root) _add_local_uv_sources(generated_pyproject, repo_root)