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"
requires-python = ">=3.10"
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",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.5.2,<2.0.0",
"arcade-mcp[all]>=1.11.2,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import subprocess
from pathlib import Path
import typer
from arcade_mcp_server.settings import find_env_file
from dotenv import dotenv_values
from arcade_cli.console import console
@ -204,10 +205,16 @@ def is_uv_installed() -> bool:
def get_tool_secrets() -> dict:
"""Only useful for stdio servers, because HTTP servers load in envvars at runtime"""
# TODO: Allow for a custom .env file to be used
env_path = Path.cwd() / ".env"
if env_path.exists():
"""Get tool secrets from .env file for stdio servers.
Discovers .env file by traversing upward from the current directory
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 {}

View file

@ -16,6 +16,7 @@ from arcade_core.subprocess_utils import (
get_windows_no_window_creationflags,
graceful_terminate_process,
)
from arcade_mcp_server.settings import find_env_file
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from rich.columns import Columns
@ -817,14 +818,14 @@ def deploy_server_logic(
)
console.print(f"✓ Entrypoint file found at {entrypoint_path}", style="green")
# Step 3: Load .env file from current directory if it exists
console.print("\nLoading .env file from current directory if it exists...", style="dim")
env_path = current_dir / ".env"
if env_path.exists():
# Step 3: Load .env file if it exists (searches upward through parent directories)
console.print("\nSearching for .env file...", style="dim")
env_path = find_env_file()
if env_path is not None:
load_dotenv(env_path, override=False)
console.print(f"✓ Loaded environment from {env_path}", style="green")
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)
required_secrets_from_validation: set[str] = set()
@ -860,7 +861,7 @@ def deploy_server_logic(
if secrets == "skip":
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")
secrets_to_upsert = set(load_env_file(str(env_path)).keys())
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.network.org_transport import build_org_scoped_http_client
from arcade_core.schema import ToolDefinition
from arcade_mcp_server.settings import find_env_file
from arcadepy import (
NOT_GIVEN,
APIConnectionError,
@ -1123,9 +1124,9 @@ def resolve_provider_api_key(provider: Provider, provider_api_key: str | None =
if api_key:
return api_key
# Then check .env file in current working directory
env_file_path = Path.cwd() / ".env"
if env_file_path.exists():
# Then check .env file by traversing upward through parent directories
env_file_path = find_env_file()
if env_file_path is not None:
load_dotenv(env_file_path, override=False)
api_key = os.getenv(env_var_name)
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.resource_server.base import ResourceServerValidator
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.usage import ServerTracker
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
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:
"""Start a child process running the server."""
@ -414,9 +414,17 @@ class MCPApp:
try:
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...")
shutdown_server_process(process, reason="reload")
process = start_server_process()

View file

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

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "arcade-mcp-server"
version = "1.17.3"
version = "1.17.4"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
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 sys
import types
@ -10,6 +12,7 @@ from arcade_cli.configure import (
_resolve_windows_appdata,
_warn_overwrite,
configure_client,
get_tool_secrets,
)
@ -30,6 +33,28 @@ def _assert_stdio_entry(entry: dict) -> None:
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()
# ---------------------------------------------------------------------------

View file

@ -15,7 +15,7 @@ def test_create_new_toolkit_minimal_with_spaces(tmp_path: Path) -> None:
server_root = output_dir / "my_server"
assert (server_root / "pyproject.toml").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:

View file

@ -41,21 +41,23 @@ def pytest_collection_modifyitems(config, items):
@pytest.fixture(autouse=True)
def disable_usage_tracking():
"""Disable CLI usage tracking for all tests.
def isolate_environment():
"""Isolate environment variables for each test.
This prevents test runs from sending analytics events to PostHog.
The fixture is autouse=True so it applies automatically to every test.
This fixture captures the entire environment before a test and restores it
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
os.environ["ARCADE_USAGE_TRACKING"] = "0"
yield
# Restore original value after test
if original_value is None:
os.environ.pop("ARCADE_USAGE_TRACKING", None)
else:
os.environ["ARCADE_USAGE_TRACKING"] = original_value
# Restore the original environment
os.environ.clear()
os.environ.update(original_env)

View file

@ -1,6 +1,6 @@
[project]
name = "arcade-mcp"
version = "1.11.1"
version = "1.11.2"
description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md"
license = { file = "LICENSE" }
@ -19,7 +19,7 @@ requires-python = ">=3.10"
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",
"typer==0.10.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"
_ensure_exists(generated_root / "pyproject.toml")
_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"
_add_local_uv_sources(generated_pyproject, repo_root)