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:
parent
c50699d5e6
commit
4a737b9710
20 changed files with 292 additions and 44 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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!"
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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" }]
|
||||||
|
|
|
||||||
111
libs/tests/arcade_mcp_server/test_env_discovery.py
Normal file
111
libs/tests/arcade_mcp_server/test_env_discovery.py
Normal 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
|
||||||
|
|
@ -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()
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue