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>
389 lines
14 KiB
Python
389 lines
14 KiB
Python
import importlib.metadata
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import types
|
|
from collections import defaultdict
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
|
|
import toml
|
|
from pydantic import BaseModel, ConfigDict, field_validator
|
|
|
|
from arcade_core.errors import ToolkitLoadError
|
|
from arcade_core.parse import get_tools_from_file
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Toolkit(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
name: str
|
|
"""Name of the toolkit"""
|
|
|
|
package_name: str
|
|
"""Name of the package holding the toolkit"""
|
|
|
|
tools: dict[str, list[str]] = defaultdict(list)
|
|
"""Mapping of module names to tools"""
|
|
|
|
# Other python package metadata
|
|
version: str
|
|
description: str
|
|
author: list[str] = []
|
|
repository: str | None = None
|
|
homepage: str | None = None
|
|
|
|
@field_validator("name", mode="before")
|
|
def strip_arcade_prefix(cls, value: str) -> str:
|
|
"""
|
|
Validator to strip the 'arcade_' prefix from the name if it exists.
|
|
"""
|
|
return cls._strip_arcade_prefix(value)
|
|
|
|
@classmethod
|
|
def _strip_arcade_prefix(cls, value: str) -> str:
|
|
"""
|
|
Strip the 'arcade_' prefix from the name if it exists.
|
|
"""
|
|
if value.startswith("arcade_"):
|
|
return value[len("arcade_") :]
|
|
return value
|
|
|
|
@classmethod
|
|
def from_module(cls, module: types.ModuleType) -> "Toolkit":
|
|
"""
|
|
Load a toolkit from an imported python module
|
|
|
|
>>> import arcade_math
|
|
>>> toolkit = Toolkit.from_module(arcade_math)
|
|
"""
|
|
return cls.from_package(module.__name__)
|
|
|
|
@classmethod
|
|
def from_directory(cls, directory: Path) -> "Toolkit":
|
|
"""
|
|
Load a Toolkit from a directory.
|
|
"""
|
|
pyproject_path = directory / "pyproject.toml"
|
|
if not pyproject_path.exists():
|
|
raise ToolkitLoadError(f"pyproject.toml not found in {directory}")
|
|
|
|
try:
|
|
with open(pyproject_path) as f:
|
|
pyproject_data = toml.load(f)
|
|
|
|
project_data = pyproject_data.get("project", {})
|
|
name = project_data.get("name")
|
|
if not name:
|
|
|
|
def _missing_name_error() -> ToolkitLoadError:
|
|
return ToolkitLoadError("name not found in pyproject.toml")
|
|
|
|
raise _missing_name_error() # noqa: TRY301
|
|
|
|
package_name = name
|
|
version = project_data.get("version", "0.0.0")
|
|
description = project_data.get("description", "")
|
|
authors = project_data.get("authors", [])
|
|
author_names = [author.get("name", "") for author in authors]
|
|
|
|
# For homepage and repository, you might need to look under project.urls
|
|
urls = project_data.get("urls", {})
|
|
homepage = urls.get("Homepage")
|
|
repo = urls.get("Repository")
|
|
|
|
except Exception as e:
|
|
raise ToolkitLoadError(f"Failed to load metadata from {pyproject_path}: {e}")
|
|
|
|
# Determine the actual package directory (supports src/ layout and flat layout)
|
|
package_dir = directory
|
|
try:
|
|
src_candidate = directory / "src" / package_name
|
|
flat_candidate = directory / package_name
|
|
if src_candidate.is_dir():
|
|
package_dir = src_candidate
|
|
elif flat_candidate.is_dir():
|
|
package_dir = flat_candidate
|
|
else:
|
|
# Fallback to the provided directory; tools_from_directory will de-duplicate prefixes
|
|
package_dir = directory
|
|
except Exception:
|
|
package_dir = directory
|
|
|
|
toolkit = cls(
|
|
name=name,
|
|
package_name=package_name,
|
|
version=version,
|
|
description=description,
|
|
author=author_names,
|
|
homepage=homepage,
|
|
repository=repo,
|
|
)
|
|
|
|
toolkit.tools = cls.tools_from_directory(package_dir, package_name)
|
|
|
|
return toolkit
|
|
|
|
@classmethod
|
|
def from_package(cls, package: str) -> "Toolkit":
|
|
"""
|
|
Load a Toolkit from a Python package
|
|
"""
|
|
try:
|
|
metadata = importlib.metadata.metadata(package)
|
|
name = metadata["Name"]
|
|
package_name = package
|
|
version = metadata["Version"]
|
|
description = metadata.get("Summary", "") # type: ignore[attr-defined]
|
|
author = metadata.get_all("Author-email")
|
|
homepage = metadata.get("Home-page", None) # type: ignore[attr-defined]
|
|
repo = metadata.get("Repository", None) # type: ignore[attr-defined]
|
|
|
|
except importlib.metadata.PackageNotFoundError as e:
|
|
raise ToolkitLoadError(f"Package '{package}' not found.") from e
|
|
except KeyError as e:
|
|
raise ToolkitLoadError(f"Metadata key error for package '{package}'.") from e
|
|
except Exception as e:
|
|
raise ToolkitLoadError(f"Failed to load metadata for package '{package}'.") from e
|
|
|
|
# Get the package directory
|
|
try:
|
|
package_dir = Path(get_package_directory(package))
|
|
except (ImportError, AttributeError) as e:
|
|
raise ToolkitLoadError(f"Failed to locate package directory for '{package}'.") from e
|
|
|
|
toolkit = cls(
|
|
name=name,
|
|
package_name=package_name,
|
|
version=version,
|
|
description=description,
|
|
author=author if author else [],
|
|
homepage=homepage,
|
|
repository=repo,
|
|
)
|
|
|
|
toolkit.tools = cls.tools_from_directory(package_dir, package_name)
|
|
|
|
return toolkit
|
|
|
|
@classmethod
|
|
def from_entrypoint(cls, entry: importlib.metadata.EntryPoint) -> "Toolkit":
|
|
"""
|
|
Load a Toolkit from an entrypoint.
|
|
|
|
The entrypoint value is used as the toolkit name, while the package name
|
|
is extracted from the distribution that owns the entrypoint.
|
|
|
|
Args:
|
|
entry: The EntryPoint object from importlib.metadata
|
|
|
|
Returns:
|
|
A Toolkit instance
|
|
|
|
Raises:
|
|
ToolkitLoadError: If the toolkit cannot be loaded
|
|
"""
|
|
# Get the package name from the distribution that owns this entrypoint
|
|
if not hasattr(entry, "dist") or entry.dist is None:
|
|
raise ToolkitLoadError(
|
|
f"Entry point '{entry.name}' does not have distribution metadata. "
|
|
f"This may indicate an incomplete package installation."
|
|
)
|
|
|
|
package_name = entry.dist.name
|
|
|
|
toolkit = cls.from_package(package_name)
|
|
toolkit.name = cls._strip_arcade_prefix(entry.value)
|
|
|
|
return toolkit
|
|
|
|
@classmethod
|
|
def find_arcade_toolkits_from_entrypoints(cls) -> list["Toolkit"]:
|
|
"""
|
|
Find and load as Toolkits all installed packages in the
|
|
current Python interpreter's environment that have a
|
|
registered entrypoint under the 'arcade.toolkits' group.
|
|
"""
|
|
toolkits = []
|
|
toolkit_entries: list[importlib.metadata.EntryPoint] = []
|
|
|
|
try:
|
|
toolkit_entries = importlib.metadata.entry_points(
|
|
group="arcade_toolkits", name="toolkit_name"
|
|
)
|
|
for entry in toolkit_entries:
|
|
try:
|
|
toolkit = cls.from_entrypoint(entry)
|
|
toolkits.append(toolkit)
|
|
logger.debug(
|
|
f"Loaded toolkit from entry point: {entry.name} = '{toolkit.name}'"
|
|
)
|
|
except ToolkitLoadError as e:
|
|
logger.warning(
|
|
f"Warning: {e} Skipping toolkit from entry point '{entry.value}'"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Entry point discovery failed or not available: {e}")
|
|
|
|
return toolkits
|
|
|
|
@classmethod
|
|
def find_arcade_toolkits_from_prefix(cls) -> list["Toolkit"]:
|
|
"""
|
|
Find and load as Toolkits all installed packages in the
|
|
current Python interpreter's environment that are prefixed with 'arcade_'.
|
|
"""
|
|
import sysconfig
|
|
|
|
toolkits = []
|
|
site_packages_dir = sysconfig.get_paths()["purelib"]
|
|
|
|
arcade_packages = [
|
|
dist.metadata["Name"]
|
|
for dist in importlib.metadata.distributions(path=[site_packages_dir])
|
|
if dist.metadata["Name"].startswith("arcade_")
|
|
]
|
|
|
|
for package in arcade_packages:
|
|
try:
|
|
toolkit = cls.from_package(package)
|
|
toolkits.append(toolkit)
|
|
logger.debug(f"Loaded toolkit from prefix discovery: {package}")
|
|
except ToolkitLoadError as e:
|
|
logger.warning(f"Warning: {e} Skipping toolkit {package}")
|
|
|
|
return toolkits
|
|
|
|
@classmethod
|
|
def find_all_arcade_toolkits(cls) -> list["Toolkit"]:
|
|
"""
|
|
Find and load as Toolkits all installed packages in the
|
|
current Python interpreter's environment that either
|
|
1. Have a registered entrypoint under the 'arcade.toolkits' group, or
|
|
2. Are prefixed with 'arcade_'
|
|
|
|
Returns:
|
|
List[Toolkit]: A list of Toolkit instances.
|
|
"""
|
|
# Find toolkits
|
|
entrypoint_toolkits = cls.find_arcade_toolkits_from_entrypoints()
|
|
prefix_toolkits = cls.find_arcade_toolkits_from_prefix()
|
|
|
|
# Deduplicate. Entrypoints are preferred over prefix-based toolkits.
|
|
seen_package_names = set()
|
|
all_toolkits = []
|
|
for toolkit in entrypoint_toolkits + prefix_toolkits:
|
|
if toolkit.package_name not in seen_package_names:
|
|
all_toolkits.append(toolkit)
|
|
seen_package_names.add(toolkit.package_name)
|
|
|
|
return all_toolkits
|
|
|
|
@classmethod
|
|
def tools_from_directory(cls, package_dir: Path, package_name: str) -> dict[str, list[str]]:
|
|
"""
|
|
Load a Toolkit from a directory.
|
|
"""
|
|
# Get all python files in the package directory
|
|
try:
|
|
modules = [f for f in package_dir.glob("**/*.py") if f.is_file() and Validate.path(f)]
|
|
except OSError as e:
|
|
raise ToolkitLoadError(
|
|
f"Failed to locate Python files in package directory for '{package_name}'."
|
|
) from e
|
|
|
|
tools: dict[str, list[str]] = {}
|
|
|
|
for module_path in modules:
|
|
relative_path = module_path.relative_to(package_dir)
|
|
cls.validate_file(module_path)
|
|
# Build import path and avoid duplicating the package prefix if it already exists
|
|
relative_parts = relative_path.with_suffix("").parts
|
|
import_path = ".".join(relative_parts)
|
|
if relative_parts and relative_parts[0] == package_name:
|
|
full_import_path = import_path
|
|
else:
|
|
full_import_path = f"{package_name}.{import_path}" if import_path else package_name
|
|
tools[full_import_path] = get_tools_from_file(str(module_path))
|
|
|
|
if not tools:
|
|
raise ToolkitLoadError(f"No tools found in package {package_name}")
|
|
|
|
return tools
|
|
|
|
@classmethod
|
|
def validate_file(cls, file_path: str | Path) -> None:
|
|
"""
|
|
Validate that the Python code in the given file is syntactically correct.
|
|
|
|
Args:
|
|
file_path: Path to the Python file to validate
|
|
"""
|
|
# Convert string path to Path object if needed
|
|
path = Path(file_path) if isinstance(file_path, str) else file_path
|
|
|
|
# Check if file exists
|
|
if not path.exists():
|
|
raise ValueError(f"❌ File not found: {path}")
|
|
|
|
# Check if it's a Python file
|
|
if not path.suffix == ".py":
|
|
raise ValueError(f"❌ Not a Python file: {path}")
|
|
|
|
try:
|
|
# Try to compile the code to check for syntax errors
|
|
with open(path, encoding="utf-8") as f:
|
|
source = f.read()
|
|
|
|
compile(source, str(path), "exec")
|
|
except Exception as e:
|
|
raise SyntaxError(f"{path}: {e}")
|
|
|
|
|
|
def get_package_directory(package_name: str) -> str:
|
|
"""
|
|
Get the directory of a Python package
|
|
"""
|
|
|
|
spec = importlib.util.find_spec(package_name)
|
|
if spec is None:
|
|
raise ImportError(f"Cannot find package named {package_name}")
|
|
|
|
if spec.origin:
|
|
# If the package has an origin, return the directory of the origin
|
|
return os.path.dirname(spec.origin)
|
|
elif spec.submodule_search_locations:
|
|
# If the package is a namespace package, return the first search location
|
|
return spec.submodule_search_locations[0]
|
|
else:
|
|
raise ImportError(f"Package {package_name} does not have a file path associated with it")
|
|
|
|
|
|
class Validate:
|
|
warn = True
|
|
|
|
@classmethod
|
|
def path(cls, path: str | Path) -> bool:
|
|
"""
|
|
Validate if a path is valid to be served or deployed.
|
|
"""
|
|
# Check both POSIX and Windows interpretations
|
|
posix_path = PurePosixPath(path)
|
|
windows_path = PureWindowsPath(path)
|
|
|
|
# Get all possible parts from both interpretations
|
|
all_parts = set(posix_path.parts) | set(windows_path.parts)
|
|
|
|
for part in all_parts:
|
|
if (part == "venv" or part.startswith(".")) and cls.warn:
|
|
print(
|
|
f"⚠️ Your package may contain a venv directory or hidden files. We suggest moving these out of the toolkit directory to avoid deployment issues: {path}"
|
|
)
|
|
cls.warn = False
|
|
if part in {"dist", "build", "__pycache__", "coverage.xml"}:
|
|
return False
|
|
if part.endswith(".lock"):
|
|
return False
|
|
|
|
return True
|