arcade-mcp/tests/integration/no_auth_cli_smoke.py
Eric Gustin 4a737b9710
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>
2026-02-25 23:20:28 -08:00

299 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Cross-platform no-auth CLI integration smoke checks.
This script runs a minimal but meaningful no-auth integration flow across all
CI operating systems:
1. Validate `arcade configure` writes client configs in a path with spaces.
2. Scaffold a new toolkit with `arcade new`.
3. Run protocol smoke checks (stdio + http) against the generated server.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, cast
def _run(
cmd: list[str],
*,
cwd: Path,
capture_output: bool = False,
) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(
cmd,
cwd=str(cwd),
text=True,
capture_output=capture_output,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(
f"Command failed ({proc.returncode}): {' '.join(cmd)}\n"
f"STDOUT:\n{proc.stdout or ''}\nSTDERR:\n{proc.stderr or ''}"
)
return proc
def _ensure_exists(path: Path) -> None:
if not path.exists():
raise RuntimeError(f"Expected path to exist: {path}")
def _load_json_object(path: Path) -> dict[str, Any]:
parsed = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(parsed, dict):
raise TypeError(f"Expected JSON object in {path}, got {type(parsed).__name__}")
return cast(dict[str, Any], parsed)
def _expect_dict(value: Any, context: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise TypeError(f"Expected object for {context}, got {type(value).__name__}")
return cast(dict[str, Any], value)
def _assert_stdio_entry(entry: dict[str, Any], context: str) -> None:
if "command" not in entry:
raise RuntimeError(f"{context}: missing 'command'")
args = entry.get("args")
if not isinstance(args, list):
raise TypeError(f"{context}: missing or invalid 'args' list")
if not any(str(arg).endswith("server.py") for arg in args):
raise RuntimeError(f"{context}: expected entrypoint in args ending with 'server.py'")
def _add_local_uv_sources(pyproject_path: Path, repo_root: Path) -> None:
pyproject_text = pyproject_path.read_text(encoding="utf-8")
if "[tool.uv.sources]" in pyproject_text:
return
repo = repo_root.resolve()
block_lines = [
"[tool.uv.sources]",
f'arcade-mcp = {{ path = "{repo.as_posix()}", editable = true }}',
f'arcade-mcp-server = {{ path = "{(repo / "libs/arcade-mcp-server").as_posix()}", editable = true }}',
f'arcade-core = {{ path = "{(repo / "libs/arcade-core").as_posix()}", editable = true }}',
f'arcade-serve = {{ path = "{(repo / "libs/arcade-serve").as_posix()}", editable = true }}',
f'arcade-tdk = {{ path = "{(repo / "libs/arcade-tdk").as_posix()}", editable = true }}',
]
pyproject_path.write_text(
pyproject_text.rstrip() + "\n\n" + "\n".join(block_lines) + "\n",
encoding="utf-8",
)
def _run_configure_smoke(repo_root: Path) -> None:
config_tmp = Path(tempfile.mkdtemp(prefix="arcade mcp config test "))
try:
(config_tmp / "server.py").write_text("print('ok')\n", encoding="utf-8")
cursor_cfg = config_tmp / "cursor config.json"
vscode_cfg = config_tmp / "vscode config.json"
claude_cfg = config_tmp / "claude config.json"
_run(
[
"uv",
"run",
"--project",
str(repo_root),
"arcade",
"configure",
"cursor",
"--name",
"demo",
"--config",
str(cursor_cfg),
],
cwd=config_tmp,
)
cursor_data = _load_json_object(cursor_cfg)
cursor_mcp_servers = _expect_dict(cursor_data.get("mcpServers"), "Cursor stdio mcpServers")
_assert_stdio_entry(
_expect_dict(cursor_mcp_servers.get("demo"), "Cursor stdio demo server"), "Cursor stdio"
)
overwrite = _run(
[
"uv",
"run",
"--project",
str(repo_root),
"arcade",
"configure",
"cursor",
"--transport",
"http",
"--port",
"8123",
"--name",
"demo",
"--config",
str(cursor_cfg),
],
cwd=config_tmp,
capture_output=True,
)
overwrite_output = (overwrite.stdout or "") + "\n" + (overwrite.stderr or "")
if "overwrite" not in overwrite_output.lower():
raise RuntimeError(
"Expected overwrite warning when configuring cursor with same --name.\n"
f"Output:\n{overwrite_output}"
)
cursor_data = _load_json_object(cursor_cfg)
cursor_mcp_servers = _expect_dict(cursor_data.get("mcpServers"), "Cursor http mcpServers")
cursor_http_demo = _expect_dict(cursor_mcp_servers.get("demo"), "Cursor http demo server")
if cursor_http_demo.get("type") != "stream":
raise RuntimeError("Cursor http config type mismatch")
if cursor_http_demo.get("url") != "http://localhost:8123/mcp":
raise RuntimeError("Cursor http config URL mismatch")
_run(
[
"uv",
"run",
"--project",
str(repo_root),
"arcade",
"configure",
"vscode",
"--name",
"demo",
"--config",
str(vscode_cfg),
],
cwd=config_tmp,
)
vscode_data = _load_json_object(vscode_cfg)
vscode_servers = _expect_dict(vscode_data.get("servers"), "VS Code stdio servers")
_assert_stdio_entry(
_expect_dict(vscode_servers.get("demo"), "VS Code stdio demo server"), "VS Code stdio"
)
_run(
[
"uv",
"run",
"--project",
str(repo_root),
"arcade",
"configure",
"vscode",
"--transport",
"http",
"--port",
"8123",
"--name",
"demo",
"--config",
str(vscode_cfg),
],
cwd=config_tmp,
)
vscode_data = _load_json_object(vscode_cfg)
vscode_servers = _expect_dict(vscode_data.get("servers"), "VS Code http servers")
vscode_http_demo = _expect_dict(vscode_servers.get("demo"), "VS Code http demo server")
if vscode_http_demo.get("type") != "http":
raise RuntimeError("VS Code http config type mismatch")
if vscode_http_demo.get("url") != "http://localhost:8123/mcp":
raise RuntimeError("VS Code http config URL mismatch")
_run(
[
"uv",
"run",
"--project",
str(repo_root),
"arcade",
"configure",
"claude",
"--name",
"demo",
"--config",
str(claude_cfg),
],
cwd=config_tmp,
)
claude_data = _load_json_object(claude_cfg)
claude_mcp_servers = _expect_dict(claude_data.get("mcpServers"), "Claude stdio mcpServers")
_assert_stdio_entry(
_expect_dict(claude_mcp_servers.get("demo"), "Claude stdio demo server"), "Claude stdio"
)
finally:
shutil.rmtree(config_tmp, ignore_errors=True)
def _run_scaffold_and_protocol_smoke(repo_root: Path) -> None:
scaffold_dir = Path(tempfile.mkdtemp(prefix="arcade scaffold with spaces "))
try:
created = _run(
[
"uv",
"run",
"arcade",
"new",
"my_server",
"--dir",
str(scaffold_dir),
],
cwd=repo_root,
capture_output=True,
)
new_output = (created.stdout or "") + "\n" + (created.stderr or "")
if "Next steps:" not in new_output:
raise RuntimeError(
"Expected 'Next steps:' output from 'arcade new'.\n" f"Output:\n{new_output}"
)
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 / ".env.example")
generated_pyproject = generated_root / "pyproject.toml"
_add_local_uv_sources(generated_pyproject, repo_root)
generated_server_dir = generated_root / "src" / "my_server"
_run(
["uv", "run", "python", "-c", "import server; print('generated server import ok')"],
cwd=generated_server_dir,
)
_run(
[
"uv",
"run",
"python",
"tests/integration/mcp_protocol_smoke.py",
"--server-dir",
str(generated_server_dir),
"--transport",
"both",
],
cwd=repo_root,
)
finally:
shutil.rmtree(scaffold_dir, ignore_errors=True)
def main() -> None:
repo_root = Path.cwd().resolve()
print(f"Repo root: {repo_root}")
os.environ["ARCADE_USAGE_TRACKING"] = "0"
_run(["uv", "--version"], cwd=repo_root)
_run_configure_smoke(repo_root)
_run_scaffold_and_protocol_smoke(repo_root)
print("Cross-platform no-auth CLI smoke checks passed.")
if __name__ == "__main__":
main()