Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions libs/arcade-cli/arcade_cli/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pathlib import Path

import typer
from arcade_mcp_server.settings import find_env_file
from dotenv import dotenv_values
from rich.console import Console

Expand Down Expand Up @@ -137,10 +138,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 {}

Expand Down
11 changes: 6 additions & 5 deletions libs/arcade-cli/arcade_cli/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import cast

import httpx
from arcade_mcp_server.settings import find_env_file
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from rich.columns import Columns
Expand Down Expand Up @@ -747,14 +748,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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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!"

This file was deleted.

7 changes: 4 additions & 3 deletions libs/arcade-cli/arcade_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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,
Expand Down Expand Up @@ -1031,9 +1032,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:
Expand Down
9 changes: 6 additions & 3 deletions libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
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
Expand Down Expand Up @@ -358,7 +358,7 @@ def _run_with_reload(self, host: str, port: int) -> None:
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."""
Expand Down Expand Up @@ -393,7 +393,10 @@ def shutdown_server_process(process: subprocess.Popen, reason: str = "reload") -
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):
logger.info(f"Detected changes in {len(changes)} file(s), restarting server...")
Expand Down
64 changes: 59 additions & 5 deletions libs/arcade-mcp-server/arcade_mcp_server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,59 @@
from pydantic_settings import BaseSettings


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 the filesystem root (or stop_at directory) is reached.

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.
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
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 project root
env_path = find_env_file(stop_at=Path("/path/to/project"))
"""
current = start_dir or Path.cwd()
current = current.resolve()

if stop_at is not None:
stop_at = stop_at.resolve()

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."""

Expand Down Expand Up @@ -308,16 +361,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()
Expand Down
2 changes: 1 addition & 1 deletion libs/arcade-mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "arcade-mcp-server"
version = "1.14.0"
version = "1.15.0"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
Expand Down
66 changes: 66 additions & 0 deletions libs/tests/arcade_mcp_server/test_env_discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""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."""
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:
"""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
1 change: 0 additions & 1 deletion libs/tests/cli/deploy/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import io
import subprocess
import tarfile
import time
from pathlib import Path

import pytest
Expand Down
28 changes: 28 additions & 0 deletions libs/tests/cli/test_configure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Tests for get_tool_secrets() in arcade configure."""

from pathlib import Path

import pytest
from arcade_cli.configure import get_tool_secrets


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() == {}
22 changes: 12 additions & 10 deletions libs/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@


@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)
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "arcade-mcp"
version = "1.7.1"
version = "1.8.0"
description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md"
license = { file = "LICENSE" }
Expand All @@ -19,7 +19,7 @@ requires-python = ">=3.10"

dependencies = [
# CLI dependencies
"arcade-mcp-server>=1.14.0,<2.0.0",
"arcade-mcp-server>=1.15.0,<2.0.0",
"arcade-core>=4.1.0,<5.0.0",
"typer==0.10.0",
"rich>=14.0.0,<15.0.0",
Expand All @@ -41,7 +41,7 @@ all = [
"pytz>=2024.1",
"python-dateutil>=2.8.2",
# mcp
"arcade-mcp-server>=1.14.0,<2.0.0",
"arcade-mcp-server>=1.15.0,<2.0.0",
# serve
"arcade-serve>=3.2.0,<4.0.0",
# tdk
Expand Down