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/authorization/pyproject.toml b/examples/mcp_servers/authorization/pyproject.toml index cbfb6dcf..6fe3d5cd 100644 --- a/examples/mcp_servers/authorization/pyproject.toml +++ b/examples/mcp_servers/authorization/pyproject.toml @@ -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", 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/simple/pyproject.toml b/examples/mcp_servers/simple/pyproject.toml index 85227a7f..7646030b 100644 --- a/examples/mcp_servers/simple/pyproject.toml +++ b/examples/mcp_servers/simple/pyproject.toml @@ -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", 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/examples/mcp_servers/tool_chaining/pyproject.toml b/examples/mcp_servers/tool_chaining/pyproject.toml index 52905f18..ba19aba5 100644 --- a/examples/mcp_servers/tool_chaining/pyproject.toml +++ b/examples/mcp_servers/tool_chaining/pyproject.toml @@ -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", diff --git a/libs/arcade-cli/arcade_cli/configure.py b/libs/arcade-cli/arcade_cli/configure.py index 05b0a592..68ca07a4 100644 --- a/libs/arcade-cli/arcade_cli/configure.py +++ b/libs/arcade-cli/arcade_cli/configure.py @@ -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 {} diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py index 715bf4d3..28d56318 100644 --- a/libs/arcade-cli/arcade_cli/deploy.py +++ b/libs/arcade-cli/arcade_cli/deploy.py @@ -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: 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 00000000..c609e360 --- /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 fe5a7446..00000000 --- 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 f3ea6fd5..54901538 100644 --- a/libs/arcade-cli/arcade_cli/utils.py +++ b/libs/arcade-cli/arcade_cli/utils.py @@ -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: 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 5864ac1e..8da039c4 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py @@ -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() diff --git a/libs/arcade-mcp-server/arcade_mcp_server/settings.py b/libs/arcade-mcp-server/arcade_mcp_server/settings.py index e8e4b8c1..26ca72d7 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/settings.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/settings.py @@ -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() diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 5facba4c..132dfa1f 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.17.3" +version = "1.17.4" 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 00000000..e45ab33b --- /dev/null +++ b/libs/tests/arcade_mcp_server/test_env_discovery.py @@ -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 diff --git a/libs/tests/cli/test_configure.py b/libs/tests/cli/test_configure.py index ac7b430d..b2d9e3de 100644 --- a/libs/tests/cli/test_configure.py +++ b/libs/tests/cli/test_configure.py @@ -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() # --------------------------------------------------------------------------- diff --git a/libs/tests/cli/test_new_cli.py b/libs/tests/cli/test_new_cli.py index 0702d2de..4b72b711 100644 --- a/libs/tests/cli/test_new_cli.py +++ b/libs/tests/cli/test_new_cli.py @@ -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: diff --git a/libs/tests/conftest.py b/libs/tests/conftest.py index 4adf1086..95be27dd 100644 --- a/libs/tests/conftest.py +++ b/libs/tests/conftest.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 11eb5abf..9620babf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/integration/no_auth_cli_smoke.py b/tests/integration/no_auth_cli_smoke.py index d828f003..d674a53d 100644 --- a/tests/integration/no_auth_cli_smoke.py +++ b/tests/integration/no_auth_cli_smoke.py @@ -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)