arcade-mcp/libs/arcade-evals/arcade_evals/loaders.py
Eric Gustin a4160dd9fe
Four bug fixes (#754)
1. Resolves
[TOO-363](https://linear.app/arcadedev/issue/TOO-363/arcade-deploy-fails-when-additional-deps-are-added-to-the-server).
2. Resolves
[TOO-364](https://linear.app/arcadedev/issue/TOO-364/arcade-cores-tool-skip-logic-is-missing-case-for-direct-execution).
3. Resolves
[TOO-358](https://linear.app/arcadedev/issue/TOO-358/missing-evals-error-message-shows-wrong-command).
4. Resolves
[TOO-365](https://linear.app/arcadedev/issue/TOO-365/arcade-evals-unit-tests-are-hanging).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes how `arcade deploy` spawns the server
process and adjusts toolkit discovery skip logic, which can affect
deployments and tool discovery; however, the changes are small and
covered by new unit/integration tests.
> 
> **Overview**
> `arcade deploy` now starts the validation server using the project’s
`.venv` interpreter (via `find_python_interpreter`) instead of the CLI’s
own `sys.executable`, preventing missing dependency failures when the
CLI is installed in an isolated env.
> 
> `arcade-core`’s `Toolkit.tools_from_directory` skip logic is hardened
to also skip the currently executing entrypoint by module name
(`__main__.__spec__.name`) when file paths don’t match (e.g., bundled
execution). CLI error printing now escapes plain messages to avoid rich
markup issues, and `arcade-evals` lock acquisition accepts an optional
timeout default.
> 
> Adds unit tests for the new toolkit skip behavior and an integration
test that boots the MCP server via direct Python invocation to mirror
deployment behavior, and bumps `arcade-core`, `arcade-mcp-server`, and
root dependency versions accordingly.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e7785634c231c059f2e0bd1bc73a56bd7470a494. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-01-29 15:12:06 -08:00

440 lines
15 KiB
Python

"""
MCP Server Tool Loaders.
Public API (async-only):
- `load_from_stdio_async`
- `load_mcp_remote_async`
- `load_arcade_mcp_gateway_async`
- `load_stdio_arcade_async`
Requires the MCP SDK: pip install mcp
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any
from urllib.parse import urlsplit, urlunsplit
logger = logging.getLogger(__name__)
class MCPSessionFilter(logging.Filter):
"""Filter to suppress/rewrite misleading MCP SDK session termination messages.
The MCP SDK logs "Session termination failed: 202" when sessions close gracefully.
HTTP 202 (Accepted) is the correct response for MCP notifications per spec,
not an error. This filter suppresses the misleading error message.
"""
def filter(self, record: logging.LogRecord) -> bool:
"""Return False to suppress log record, True to allow it."""
message = record.getMessage()
# Suppress the misleading "Session termination failed: 202" message
# HTTP 202 is the correct response for MCP session close notifications
is_termination_message = "Session termination failed" in message
has_202_code = "202" in message
return not (is_termination_message and has_202_code)
# Apply filter to MCP SDK loggers to suppress misleading session messages
def _configure_mcp_logging() -> None:
"""Configure MCP SDK logging to suppress misleading messages."""
mcp_loggers = [
"mcp",
"mcp.client",
"mcp.client.session",
"mcp.client.sse",
"mcp.client.stdio",
"mcp.client.streamable_http",
]
session_filter = MCPSessionFilter()
for logger_name in mcp_loggers:
mcp_logger = logging.getLogger(logger_name)
mcp_logger.addFilter(session_filter)
# Configure MCP logging on module import
_configure_mcp_logging()
# =============================================================================
# CONFIGURATION CONSTANTS
# =============================================================================
# Default Arcade API base URL (production)
ARCADE_API_BASE_URL = "https://api.arcade.dev"
# =============================================================================
# TOOL CACHE - Prevents redundant connections to the same MCP source
# Uses asyncio locks to prevent concurrent loads to the same MCP source
# =============================================================================
# Cache for loaded tools: key is (url, headers_hash), value is list of tools
_tools_cache: dict[str, list[dict[str, Any]]] = {}
# Per-key asyncio locks to prevent concurrent loads to same source
_cache_locks: dict[str, asyncio.Lock] = {}
def _make_cache_key(url: str, headers: dict[str, str] | None) -> str:
"""Create a cache key from URL and headers."""
headers_str = str(sorted((headers or {}).items()))
return f"{url}|{headers_str}"
# Lock acquisition timeout (seconds) - prevents indefinite hangs
LOCK_TIMEOUT_SECONDS = 60
async def _get_cache_lock(cache_key: str) -> asyncio.Lock:
"""Get or create an asyncio lock for the given cache key."""
if cache_key not in _cache_locks:
_cache_locks[cache_key] = asyncio.Lock()
return _cache_locks[cache_key]
async def _acquire_lock_with_timeout(lock: asyncio.Lock, timeout: float | None = None) -> bool:
"""Acquire a lock with timeout. Returns True if acquired, False on timeout."""
if timeout is None:
timeout = LOCK_TIMEOUT_SECONDS
try:
await asyncio.wait_for(lock.acquire(), timeout=timeout)
except asyncio.TimeoutError:
return False
else:
return True
def clear_tools_cache() -> None:
"""Clear the tools cache. Useful for testing or forcing fresh connections."""
_tools_cache.clear()
_cache_locks.clear()
def _get_arcade_base_url() -> str:
"""Get the Arcade API base URL, checking env var at runtime."""
return os.environ.get("ARCADE_API_BASE_URL", ARCADE_API_BASE_URL)
# =============================================================================
# MCP SDK IMPORT
# =============================================================================
def _require_mcp() -> tuple[Any, Any, Any, Any, Any]:
"""
Import MCP SDK with a helpful error message.
Returns:
(ClientSession, StdioServerParameters, stdio_client, sse_client, streamablehttp_client)
"""
try:
import mcp
import mcp.client.sse as mcp_client_sse
import mcp.client.stdio as mcp_client_stdio
import mcp.client.streamable_http as mcp_client_http
ClientSession = mcp.ClientSession
StdioServerParameters = mcp.StdioServerParameters
stdio_client = mcp_client_stdio.stdio_client
sse_client = mcp_client_sse.sse_client
streamablehttp_client = mcp_client_http.streamablehttp_client
except ImportError as e:
raise ImportError(
"MCP SDK is required for arcade-evals. "
"Install with: pip install 'arcade-mcp[evals]' or pip install mcp"
) from e
return ClientSession, StdioServerParameters, stdio_client, sse_client, streamablehttp_client
# =============================================================================
# UTILITIES
# =============================================================================
def _tool_to_dict(tool: Any) -> dict[str, Any]:
"""Convert an MCP Tool object to the MCP-style dict format used by EvalSuite."""
return {
"name": tool.name,
"description": tool.description or "",
"inputSchema": tool.inputSchema,
}
def _ensure_mcp_path(url: str) -> str:
"""Ensure the URL path ends with '/mcp' (without duplicating).
Preserves query strings and fragments.
"""
parts = urlsplit(url)
path = (parts.path or "").rstrip("/")
# If any path segment is already "mcp" (e.g. "/mcp" or "/mcp/{slug}" or "/foo/mcp"),
# treat it as already pointing at an MCP endpoint.
segments = [seg for seg in path.split("/") if seg]
if "mcp" in segments:
normalized_path = "/" + "/".join(segments) if segments else ""
return urlunsplit((
parts.scheme,
parts.netloc,
normalized_path,
parts.query,
parts.fragment,
))
new_path = (f"{path}/mcp" if path else "/mcp") if path != "" else "/mcp"
return urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment))
def _build_arcade_mcp_url(gateway_slug: str | None, base_url: str) -> str:
"""Build the Arcade MCP gateway URL."""
base = base_url.rstrip("/")
if gateway_slug:
return f"{base}/mcp/{gateway_slug}"
return f"{base}/mcp"
# =============================================================================
# PUBLIC API (async-only)
# =============================================================================
async def load_from_stdio_async(
command: list[str],
*,
env: dict[str, str] | None = None,
timeout: int = 10,
) -> list[dict[str, Any]]:
"""
Load tools from an MCP server via stdio.
Results are cached by command to avoid starting multiple subprocesses
for the same server. Concurrent requests for the same command will wait
for the first request to complete and share the result.
Args:
command: Command to run the MCP server (e.g., ["python", "server.py"]).
env: Additional environment variables to pass to the server.
timeout: Timeout in seconds (not used by MCP SDK, kept for API compatibility).
Returns:
List of tool definitions in MCP format.
"""
if not command:
return []
del timeout # MCP SDK manages timeouts internally
cache_key = f"stdio|{' '.join(command)}|{sorted((env or {}).items())!s}"
# Fast path: check cache without lock (no locking overhead for cache hits)
if cache_key in _tools_cache:
logger.debug(f"Using cached tools for stdio: {command[0]}")
return _tools_cache[cache_key].copy()
# Cache miss - acquire lock and check again (double-checked locking)
lock = await _get_cache_lock(cache_key)
if not await _acquire_lock_with_timeout(lock):
raise TimeoutError(f"Timeout waiting for lock on stdio: {command[0]}")
try:
# Re-check cache (another request may have populated it while we waited)
if cache_key in _tools_cache:
logger.debug(f"Using cached tools for stdio: {command[0]}")
return _tools_cache[cache_key].copy()
ClientSession, StdioServerParameters, stdio_client, _, _ = _require_mcp()
process_env = os.environ.copy()
if env:
process_env.update(env)
server_params = StdioServerParameters(
command=command[0],
args=command[1:] if len(command) > 1 else [],
env=process_env,
)
async with (
stdio_client(server_params) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
result = await session.list_tools()
tools = [_tool_to_dict(tool) for tool in result.tools]
# Cache the result
_tools_cache[cache_key] = tools.copy()
return tools
finally:
lock.release()
async def load_mcp_remote_async(
url: str,
*,
headers: dict[str, str] | None = None,
timeout: int = 10,
use_sse: bool = False,
) -> list[dict[str, Any]]:
"""
Load tools from a remote MCP server via URL (HTTP or SSE transport).
Results are cached to avoid redundant connections when multiple models
load the same MCP source. Concurrent requests for the same URL will wait
for the first request to complete and share the result.
Args:
url: URL of the MCP server.
headers: Additional headers to send with the request.
timeout: Timeout in seconds (not used by MCP SDK, kept for API compatibility).
use_sse: Whether to use SSE transport. If False, uses streamable-http.
Returns:
List of tool definitions in MCP format.
"""
del timeout # MCP SDK manages timeout internally
url = _ensure_mcp_path(url)
cache_key = _make_cache_key(url, headers)
# Fast path: check cache without lock (no locking overhead for cache hits)
if cache_key in _tools_cache:
logger.debug(f"Using cached tools for {url}")
return _tools_cache[cache_key].copy()
# Cache miss - acquire lock and check again (double-checked locking)
lock = await _get_cache_lock(cache_key)
if not await _acquire_lock_with_timeout(lock):
raise TimeoutError(f"Timeout waiting for lock on HTTP: {url}")
try:
# Re-check cache (another request may have populated it while we waited)
if cache_key in _tools_cache:
logger.debug(f"Using cached tools for {url}")
return _tools_cache[cache_key].copy()
# Load MCP SDK (deferred import)
ClientSession, _, _, sse_client, streamablehttp_client = _require_mcp()
# Load from MCP server
tools: list[dict[str, Any]] = []
if use_sse:
async with (
sse_client(url, headers=headers) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
result = await session.list_tools()
tools = [_tool_to_dict(tool) for tool in result.tools]
else:
async with (
streamablehttp_client(url, headers=headers) as (read, write, _),
ClientSession(read, write) as session,
):
await session.initialize()
result = await session.list_tools()
tools = [_tool_to_dict(tool) for tool in result.tools]
# Cache the result
_tools_cache[cache_key] = tools.copy()
return tools
finally:
lock.release()
async def load_arcade_mcp_gateway_async(
gateway_slug: str | None = None,
*,
arcade_api_key: str | None = None,
arcade_user_id: str | None = None,
base_url: str | None = None,
timeout: int = 10,
) -> list[dict[str, Any]]:
"""
Load tools from an Arcade MCP gateway.
Args:
gateway_slug: Optional gateway slug (if None, connects to base MCP endpoint).
arcade_api_key: Arcade API key (defaults to ARCADE_API_KEY env var).
arcade_user_id: Arcade user ID (defaults to ARCADE_USER_ID env var).
base_url: Arcade API base URL (defaults to ARCADE_API_BASE_URL env var or production).
timeout: Timeout in seconds (not used by MCP SDK, kept for API compatibility).
Returns:
List of tool definitions in MCP format (deduplicated by name).
"""
api_key = arcade_api_key or os.environ.get("ARCADE_API_KEY")
user_id = arcade_user_id or os.environ.get("ARCADE_USER_ID")
headers: dict[str, str] = {}
if api_key:
# Arcade Gateway expects "Bearer <token>" format
if api_key.startswith("Bearer "):
headers["Authorization"] = api_key
else:
headers["Authorization"] = f"Bearer {api_key}"
if user_id:
# Note: Header is "Arcade-User-Id" (not "Arcade-User-ID")
headers["Arcade-User-Id"] = user_id
# Use provided base_url or check env var at runtime
effective_base_url = base_url or _get_arcade_base_url()
url = _build_arcade_mcp_url(gateway_slug, effective_base_url)
tools = await load_mcp_remote_async(url, headers=headers, timeout=timeout)
# Deduplicate tools by name (gateway may return duplicates)
seen: dict[str, dict[str, Any]] = {}
for tool in tools:
name = tool.get("name")
if name and name not in seen:
seen[name] = tool
return list(seen.values())
async def load_stdio_arcade_async(
command: list[str],
*,
arcade_api_key: str | None = None,
arcade_user_id: str | None = None,
tool_secrets: dict[str, str] | None = None,
timeout: int = 10,
) -> list[dict[str, Any]]:
"""
Load tools from an Arcade MCP server via stdio.
Convenience wrapper that sets Arcade env vars and delegates to `load_from_stdio_async`.
Args:
command: Command to run the MCP server (e.g., ["python", "server.py"]).
arcade_api_key: Arcade API key (defaults to ARCADE_API_KEY env var).
arcade_user_id: Arcade user ID (defaults to ARCADE_USER_ID env var).
tool_secrets: Additional secrets to pass as environment variables.
timeout: Timeout in seconds.
Returns:
List of tool definitions in MCP format.
"""
env: dict[str, str] = {}
if arcade_api_key:
env["ARCADE_API_KEY"] = arcade_api_key
elif "ARCADE_API_KEY" in os.environ:
env["ARCADE_API_KEY"] = os.environ["ARCADE_API_KEY"]
if arcade_user_id:
env["ARCADE_USER_ID"] = arcade_user_id
elif "ARCADE_USER_ID" in os.environ:
env["ARCADE_USER_ID"] = os.environ["ARCADE_USER_ID"]
if tool_secrets:
env.update(tool_secrets)
return await load_from_stdio_async(command, timeout=timeout, env=env if env else None)