arcade-mcp/libs/arcade-core/arcade_core/toolkit.py
Eric Gustin 3424ec8219
MCP Local (#563)
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>
2025-09-25 15:28:15 -07:00

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