arcade-mcp/libs/arcade-core/arcade_core/discovery.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

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