diff --git a/examples/mcp_servers/authorization/src/authorization/.env.example b/examples/mcp_servers/authorization/.env.example similarity index 100% rename from examples/mcp_servers/authorization/src/authorization/.env.example rename to examples/mcp_servers/authorization/.env.example diff --git a/examples/mcp_servers/simple/src/simple/.env.example b/examples/mcp_servers/simple/.env.example similarity index 100% rename from examples/mcp_servers/simple/src/simple/.env.example rename to examples/mcp_servers/simple/.env.example diff --git a/examples/mcp_servers/tool_chaining/src/tool_chaining/.env.example b/examples/mcp_servers/tool_chaining/.env.example similarity index 100% rename from examples/mcp_servers/tool_chaining/src/tool_chaining/.env.example rename to examples/mcp_servers/tool_chaining/.env.example diff --git a/libs/arcade-cli/arcade_cli/configure.py b/libs/arcade-cli/arcade_cli/configure.py index 50ea47c95..7e3e7256c 100644 --- a/libs/arcade-cli/arcade_cli/configure.py +++ b/libs/arcade-cli/arcade_cli/configure.py @@ -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 @@ -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 {} diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py index 82a64ff01..ffebb1d20 100644 --- a/libs/arcade-cli/arcade_cli/deploy.py +++ b/libs/arcade-cli/arcade_cli/deploy.py @@ -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 @@ -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() diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/.env.example b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/.env.example new file mode 100644 index 000000000..c609e3601 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/.env.example @@ -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!" diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/.env.example b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/.env.example deleted file mode 100644 index fe5a74469..000000000 --- a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/.env.example +++ /dev/null @@ -1 +0,0 @@ -MY_SECRET_KEY="Your tools can have secrets injected at runtime!" diff --git a/libs/arcade-cli/arcade_cli/utils.py b/libs/arcade-cli/arcade_cli/utils.py index 256be8e5e..d3536d2ca 100644 --- a/libs/arcade-cli/arcade_cli/utils.py +++ b/libs/arcade-cli/arcade_cli/utils.py @@ -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, @@ -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: diff --git a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py index 91c594715..242ed82d1 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py @@ -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 @@ -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.""" @@ -393,9 +393,17 @@ 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 + ) + + # 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(".", watch_filter=watch_filter): + 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() diff --git a/libs/arcade-mcp-server/arcade_mcp_server/settings.py b/libs/arcade-mcp-server/arcade_mcp_server/settings.py index e8e4b8c1e..1a819b1c4 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/settings.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/settings.py @@ -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.""" @@ -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() diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 71feef519..ad6bab7d2 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -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" }] diff --git a/libs/tests/arcade_mcp_server/test_env_discovery.py b/libs/tests/arcade_mcp_server/test_env_discovery.py new file mode 100644 index 000000000..2d35bdc58 --- /dev/null +++ b/libs/tests/arcade_mcp_server/test_env_discovery.py @@ -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 diff --git a/libs/tests/cli/deploy/test_deploy.py b/libs/tests/cli/deploy/test_deploy.py index 0a6768bc6..ff7bf3638 100644 --- a/libs/tests/cli/deploy/test_deploy.py +++ b/libs/tests/cli/deploy/test_deploy.py @@ -2,7 +2,6 @@ import io import subprocess import tarfile -import time from pathlib import Path import pytest diff --git a/libs/tests/cli/test_configure.py b/libs/tests/cli/test_configure.py new file mode 100644 index 000000000..4d86b9985 --- /dev/null +++ b/libs/tests/cli/test_configure.py @@ -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() == {} diff --git a/libs/tests/conftest.py b/libs/tests/conftest.py index 71eee2228..61ffe46a0 100644 --- a/libs/tests/conftest.py +++ b/libs/tests/conftest.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index b7cd782eb..8b90897db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } @@ -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", @@ -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