Versions: * arcade-mcp\==1.0.0rc1 * arcade-mcp-server\==1.0.0rc1 * arcade-core\==2.5.0rc1 * arcade-tdk\==2.6.0rc1 * arcade-serve\==2.2.0rc1 ### Summary Adds first-class MCP support across Arcade, introduces a new MCP server and CLI, unifies the project under the arcade-mcp name, overhauls templates/scaffolding, and improves developer tooling, secrets management, and examples. ### Highlights - **MCP Server & Core** - New MCP server with stdio and HTTP/SSE transports, session management, resumability, and lifecycle handling. - FastAPI-like `MCPApp` for building servers with lazy init; integrated worker+MCP HTTP app option. - Middleware system (logging and error handling), robust exception hierarchy, and Pydantic-based settings. - Async-safe managers for tools, resources, and prompts backed by registries and locks. - Developer-facing, transport-agnostic runtime context interfaces (logs, tools, prompts, resources, sampling, UI, notifications). - Conversion from Arcade ToolDefinition to MCP tool schema; OpenAI JSON tool schema converter. - Parser supports `@app.tool`/`@app.tool(...)` decorators. - **CLI** - New `mcp` command to run MCP servers with stdio or HTTP/SSE. - New `secret` command to set/list/unset tool secrets (supports .env input, preserves original casing for lookups). - `new` command refactored; option to create a full toolkit package with scaffolding. - `chat` command removed. - `serve.py` imports updated to `arcade_serve.fastapi.telemetry`; version retrieval now uses `arcade-mcp`. - `show.py` refactor to use new local catalog utilities. - `display_tool_details` improved: adds “Default” column and handles nested properties. - **Configuration & Discovery** - New `configure.py` to set up Claude Desktop, Cursor, and VS Code to connect to local or Arcade Cloud MCP servers. - Discovery utilities to find/install toolkits, build `ToolCatalog`s, analyze files for tools, load kits from directories (pyproject parsing), and build minimal toolkits. - Better handling of provider API key resolution and evaluation suite loading. - **Templates & Scaffolding** - Reorganized template structure (minimal vs full); moved `.pre-commit-config.yaml`, `.ruff.toml`, license, Makefile, README, tests, and tools layout to correct paths. - Minimal template adds `.env.example` for runtime secret injection. - Template pyproject updated for MCP servers; includes sample server with greeting and secret-reveal tools. - Authorization flow in templates simplified. - **Repo-wide Renaming & Examples** - Migrates references from `arcade-ai` to `arcade-mcp` across READMEs, scripts, and package metadata. - Examples updated (LangChain/LangGraph/AI SDK/TypeScript) and package name changed to `arcade-mcp-sdk`. - **Evals & Core Utilities** - Evals now use OpenAI tooling format (`OpenAIToolList`, `to_openai`); `tool_eval` takes `provider_api_key`. - Core utilities: fixed `does_function_return_value` by dedenting before parse; version bump to `2.5.0rc1` and dependency cleanup. - **Tooling & CI** - `setup-uv-env` action splits toolkit vs contrib dependency installation. - Pre-commit: excludes `libs/arcade-mcp-server/mkdocs.yml` and `libs/tests/` from YAML and Ruff hooks; Ruff per-file ignores (e.g., C901 in `libs/**/*.py`, TRY400 in server docs paths). - Makefile updates for uv env setup, quality checks, tests, builds, and new `shell` target. - Added Makefile to MCP server library to streamline dev workflow. - **Cleanup** - Removed `claude.json` config. - Simplified stdio entrypoint; removed unused imports (`arcade_gmail`, `arcade_search`). ### Breaking Changes - **CLI**: `chat` command removed; use `mcp`, `secret`, and updated `new`. - **Naming**: All users should update references from `arcade-ai` to `arcade-mcp`. - **Templates**: File paths moved; downstream scripts referencing old template locations may need updates. ### Getting Started - Run an MCP server: - `arcade mcp --stdio --toolkits your_toolkit` - `arcade mcp --http --toolkits your_toolkit` - Manage secrets: - `arcade secret set your_toolkit KEY=value` - `arcade secret list your_toolkit` - `arcade secret unset your_toolkit KEY` - Configure clients: - `arcade configure` to set up Claude Desktop, Cursor, and VS Code for local/Arcade Cloud MCP. --------- Co-authored-by: Sam Partee <sam@arcade-ai.com> Co-authored-by: Shub <125150494+shubcodes@users.noreply.github.com>
253 lines
8.2 KiB
Python
253 lines
8.2 KiB
Python
"""
|
|
Discovery utilities for Arcade Tools.
|
|
|
|
Provides modular, testable functions to discover toolkits and local tool files,
|
|
load modules, collect tools, and build a ToolCatalog.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from arcade_core.catalog import ToolCatalog
|
|
from arcade_core.parse import get_tools_from_file
|
|
from arcade_core.toolkit import Toolkit, ToolkitLoadError
|
|
|
|
DISCOVERY_PATTERNS = ["*.py", "tools/*.py", "arcade_tools/*.py", "tools/**/*.py"]
|
|
FILTER_PATTERNS = ["_test.py", "test_*.py", "__pycache__", "*.lock", "*.egg-info", "*.pyc"]
|
|
|
|
|
|
def normalize_package_name(package_name: str) -> str:
|
|
"""Normalize a package name for import resolution."""
|
|
return package_name.lower().replace("-", "_")
|
|
|
|
|
|
def load_toolkit_from_package(package_name: str, show_packages: bool = False) -> Toolkit:
|
|
"""Attempt to load a Toolkit from an installed package name."""
|
|
toolkit = Toolkit.from_package(package_name)
|
|
if show_packages:
|
|
logger.info(f"Loading package: {toolkit.name}")
|
|
return toolkit
|
|
|
|
|
|
def load_package(package_name: str, show_packages: bool = False) -> Toolkit:
|
|
"""Load a toolkit for a specific package name.
|
|
|
|
Raises ToolkitLoadError if the package is not found.
|
|
"""
|
|
normalized = normalize_package_name(package_name)
|
|
try:
|
|
return load_toolkit_from_package(normalized, show_packages)
|
|
except ToolkitLoadError:
|
|
return load_toolkit_from_package(f"arcade_{normalized}", show_packages)
|
|
|
|
|
|
def find_candidate_tool_files(root: Path | None = None) -> list[Path]:
|
|
"""Find candidate Python files for auto-discovery in common locations."""
|
|
cwd = root or Path.cwd()
|
|
|
|
candidates: list[Path] = []
|
|
for pattern in DISCOVERY_PATTERNS:
|
|
candidates.extend(cwd.glob(pattern))
|
|
# Deduplicate candidates (same file might match multiple patterns)
|
|
unique_candidates = list(set(candidates))
|
|
# Filter out private, cache, and tests
|
|
return [
|
|
p for p in unique_candidates if not any(p.match(pattern) for pattern in FILTER_PATTERNS)
|
|
]
|
|
|
|
|
|
def analyze_files_for_tools(files: list[Path]) -> list[tuple[Path, list[str]]]:
|
|
"""Parse files with a fast AST pass to find declared @tool function names."""
|
|
results: list[tuple[Path, list[str]]] = []
|
|
for file_path in files:
|
|
try:
|
|
names = get_tools_from_file(file_path)
|
|
if names:
|
|
logger.info(f"Found {len(names)} tool(s) in {file_path.name}: {', '.join(names)}")
|
|
results.append((file_path, names))
|
|
except Exception:
|
|
logger.exception(f"Could not parse {file_path}")
|
|
return results
|
|
|
|
|
|
def load_module_from_path(file_path: Path) -> ModuleType:
|
|
"""Dynamically import a Python module from a file path."""
|
|
import sys
|
|
|
|
# Add the directory containing the file to sys.path temporarily
|
|
# This allows local imports to work
|
|
file_dir = str(file_path.parent)
|
|
path_added = False
|
|
if file_dir not in sys.path:
|
|
sys.path.insert(0, file_dir)
|
|
path_added = True
|
|
|
|
try:
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"_tools_{file_path.stem}",
|
|
file_path,
|
|
)
|
|
if not spec or not spec.loader:
|
|
raise ToolkitLoadError(f"Unable to create import spec for {file_path}")
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except Exception:
|
|
logger.exception(f"Failed to load {file_path}")
|
|
raise ToolkitLoadError(f"Failed to load {file_path}")
|
|
|
|
return module
|
|
finally:
|
|
# Remove the path we added
|
|
if path_added and file_dir in sys.path:
|
|
sys.path.remove(file_dir)
|
|
|
|
|
|
def collect_tools_from_modules(
|
|
files_with_tools: list[tuple[Path, list[str]]],
|
|
) -> list[tuple[Any, ModuleType]]:
|
|
"""Load modules and collect the expected tool callables.
|
|
|
|
Returns a list of (callable, module) pairs.
|
|
"""
|
|
discovered: list[tuple[Any, ModuleType]] = []
|
|
|
|
for file_path, expected_names in files_with_tools:
|
|
logger.debug(f"Loading tools from {file_path}...")
|
|
try:
|
|
module = load_module_from_path(file_path)
|
|
except ToolkitLoadError:
|
|
continue
|
|
|
|
for name in expected_names:
|
|
if hasattr(module, name):
|
|
attr = getattr(module, name)
|
|
if callable(attr) and hasattr(attr, "__tool_name__"):
|
|
discovered.append((attr, module))
|
|
else:
|
|
logger.warning(
|
|
f"Expected {name} to be a tool but it wasn't (missing __tool_name__)\n\n"
|
|
)
|
|
return discovered
|
|
|
|
|
|
def build_minimal_toolkit(
|
|
server_name: str | None,
|
|
server_version: str | None,
|
|
description: str | None = None,
|
|
) -> Toolkit:
|
|
"""Create a minimal Toolkit to host locally discovered tools."""
|
|
name = server_name or "ArcadeMCP"
|
|
version = server_version or "0.1.0dev"
|
|
pkg = f"{name}.{Path.cwd().name}"
|
|
desc = description or f"MCP Server for {name} version {version}"
|
|
return Toolkit(name=name, package_name=pkg, version=version, description=desc)
|
|
|
|
|
|
def build_catalog_from_toolkits(toolkits: list[Toolkit]) -> ToolCatalog:
|
|
"""Create a ToolCatalog and add the provided toolkits."""
|
|
catalog = ToolCatalog()
|
|
for tk in toolkits:
|
|
catalog.add_toolkit(tk)
|
|
return catalog
|
|
|
|
|
|
def add_discovered_tools(
|
|
catalog: ToolCatalog,
|
|
toolkit: Toolkit,
|
|
tools: list[tuple[Any, ModuleType]],
|
|
) -> None:
|
|
"""Add discovered local tools to the catalog, preserving module context."""
|
|
for tool_func, module in tools:
|
|
if module.__name__ not in __import__("sys").modules:
|
|
__import__("sys").modules[module.__name__] = module
|
|
catalog.add_tool(tool_func, toolkit, module)
|
|
|
|
|
|
def load_toolkits_for_option(tool_package: str, show_packages: bool = False) -> list[Toolkit]:
|
|
"""
|
|
Load toolkits for a given package option.
|
|
|
|
Args:
|
|
tool_package: Package name or comma-separated list of package names
|
|
show_packages: Whether to log loaded packages
|
|
|
|
Returns:
|
|
List of loaded toolkits
|
|
"""
|
|
toolkits = []
|
|
packages = [p.strip() for p in tool_package.split(",")]
|
|
|
|
for package in packages:
|
|
try:
|
|
toolkit = load_package(package, show_packages)
|
|
toolkits.append(toolkit)
|
|
except ToolkitLoadError as e:
|
|
logger.warning(f"Failed to load package '{package}': {e}")
|
|
|
|
return toolkits
|
|
|
|
|
|
def load_all_installed_toolkits(show_packages: bool = False) -> list[Toolkit]:
|
|
"""
|
|
Discover and load all installed arcade toolkits.
|
|
|
|
Args:
|
|
show_packages: Whether to log loaded packages
|
|
|
|
Returns:
|
|
List of all installed toolkits
|
|
"""
|
|
toolkits = Toolkit.find_all_arcade_toolkits()
|
|
|
|
if show_packages:
|
|
for toolkit in toolkits:
|
|
logger.info(f"Loading package: {toolkit.name}")
|
|
|
|
return toolkits
|
|
|
|
|
|
def discover_tools(
|
|
tool_package: str | None = None,
|
|
show_packages: bool = False,
|
|
discover_installed: bool = False,
|
|
server_name: str | None = None,
|
|
server_version: str | None = None,
|
|
) -> ToolCatalog:
|
|
"""High-level discovery that returns a ToolCatalog.
|
|
|
|
This function is pure (does not sys.exit); callers should handle errors.
|
|
"""
|
|
# 1) Package-based discovery
|
|
if tool_package:
|
|
toolkits = load_toolkits_for_option(tool_package, show_packages)
|
|
return build_catalog_from_toolkits(toolkits)
|
|
|
|
# 2) Discover all installed packages
|
|
if discover_installed:
|
|
toolkits = load_all_installed_toolkits(show_packages)
|
|
return build_catalog_from_toolkits(toolkits)
|
|
|
|
# 3) Local file discovery
|
|
logger.info("Auto-discovering tools from current directory")
|
|
files = find_candidate_tool_files()
|
|
if not files:
|
|
# Return empty catalog; caller can decide how to handle
|
|
return ToolCatalog()
|
|
|
|
files_with_tools = analyze_files_for_tools(files)
|
|
if not files_with_tools:
|
|
return ToolCatalog()
|
|
|
|
discovered = collect_tools_from_modules(files_with_tools)
|
|
catalog = ToolCatalog()
|
|
toolkit = build_minimal_toolkit(server_name, server_version)
|
|
add_discovered_tools(catalog, toolkit, discovered)
|
|
return catalog
|