### Overview Major restructuring from monolithic `arcade-ai` package to modular library architecture with standardized uv-based dependency management.  ### New Package Structure - **`arcade-tdk`** - Lightweight toolkit development kit (core decorators, auth) - **`arcade-core`** - Core execution engine and catalog functionality - **`arcade-serve`** - FastAPI/MCP server components - **`arcade-ai`** - Meta package that includes CLI functionality. Optionally include evals via the `evals` extra. Optionally include all packages via the `all` extra. ### Key Benefits - **Lighter Dependencies**: Toolkits now depend only on `arcade-tdk` (~2 deps) vs full `arcade-ai` (~30+ deps) - **Faster Builds**: uv provides 10-100x faster dependency resolution and installation - **Better Modularity**: Clear separation of concerns, consumers import only what they need - **Standard Tooling**: Eliminates custom poetry scripts, uses standard Python packaging ### Migration Impact - All 20 toolkits converted from poetry → uv with `arcade-tdk` dependencies plus `arcade-ai[evals]` and `arcade-serve` dev dependencies. When developing locally, devs should install toolkits via `make install-local`. - Modern Python 3.10+ type hints throughout - Standardized build system with hatchling backend - Enhanced Makefile with robust toolkit management commands - Removed `arcade dev` CLI command - Reduce the number of files created by `arcade new` and add an option to not generate a tests and evals folder. This foundation enables faster development cycles and cleaner dependency chains for the growing toolkit ecosystem. ### Todo After this PR is merged - [ ] Post-merge workflow(s) (release & publish containers, etc) - [ ] Release order plan. @EricGustin suggests releasing in the following order: 1. `arcade-core` version 0.1.0 2. `arcade-serve` version 0.1.0 and `arcade-tdk` version 0.1.0 3. `arcade-ai` version 2.0.0 4. Patch release for all toolkits (all changes in toolkits are internal refactors) - [ ] [Update docs](https://github.com/ArcadeAI/docs/pull/318) --------- Co-authored-by: Eric Gustin <eric@arcade.dev> Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
import json
|
|
import logging
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
from arcade_core.catalog import MaterializedTool
|
|
|
|
# Type aliases for MCP types
|
|
MCPTool = dict[str, Any]
|
|
MCPTextContent = dict[str, Any]
|
|
MCPImageContent = dict[str, Any]
|
|
MCPEmbeddedResource = dict[str, Any]
|
|
MCPContent = MCPTextContent | MCPImageContent | MCPEmbeddedResource
|
|
|
|
logger = logging.getLogger("arcade.mcp")
|
|
|
|
|
|
def create_mcp_tool(tool: MaterializedTool) -> dict[str, Any] | None: # noqa: C901
|
|
"""
|
|
Create an MCP-compatible tool definition from an Arcade tool.
|
|
|
|
Args:
|
|
tool: An Arcade tool object
|
|
|
|
Returns:
|
|
An MCP tool definition or None if the tool cannot be converted
|
|
"""
|
|
try:
|
|
name = getattr(tool.definition, "fully_qualified_name", None) or getattr(
|
|
tool.definition, "name", "unknown"
|
|
)
|
|
description = getattr(tool.definition, "description", "No description available")
|
|
|
|
# Extract parameters from the input model
|
|
parameters = {}
|
|
required = []
|
|
|
|
if (
|
|
hasattr(tool, "input_model")
|
|
and tool.input_model is not None
|
|
and hasattr(tool.input_model, "model_fields")
|
|
):
|
|
for field_name, field in tool.input_model.model_fields.items():
|
|
# Skip internal tool context parameters
|
|
if field_name == getattr(
|
|
tool.definition.input, "tool_context_parameter_name", None
|
|
):
|
|
continue
|
|
|
|
# Get field type information
|
|
field_type = getattr(field, "annotation", None)
|
|
field_type_name = "string" # default
|
|
|
|
# Safety check for field_type
|
|
if field_type is int:
|
|
field_type_name = "integer"
|
|
elif field_type is float:
|
|
field_type_name = "number"
|
|
elif field_type is bool:
|
|
field_type_name = "boolean"
|
|
elif field_type is list or str(field_type).startswith("list["):
|
|
field_type_name = "array"
|
|
elif field_type is dict or str(field_type).startswith("dict["):
|
|
field_type_name = "object"
|
|
|
|
# Get description with fallback
|
|
field_description = getattr(field, "description", None)
|
|
if not field_description:
|
|
field_description = f"Parameter: {field_name}"
|
|
|
|
# Create parameter definition
|
|
param_def = {
|
|
"type": field_type_name,
|
|
"description": field_description,
|
|
}
|
|
|
|
# Enum support: if the field annotation is an Enum, add allowed values
|
|
enum_type = None
|
|
if hasattr(field, "annotation"):
|
|
ann = field.annotation
|
|
# Handle typing.Annotated[Enum, ...]
|
|
if getattr(ann, "__origin__", None) is not None and hasattr(ann, "__args__"):
|
|
for arg in ann.__args__: # type: ignore[union-attr]
|
|
if isinstance(arg, type) and issubclass(arg, Enum):
|
|
enum_type = arg
|
|
break
|
|
elif isinstance(ann, type) and issubclass(ann, Enum):
|
|
enum_type = ann
|
|
if enum_type is not None:
|
|
param_def["enum"] = [e.value for e in enum_type]
|
|
|
|
parameters[field_name] = param_def
|
|
|
|
# In Pydantic v2, check if field is required based on default value
|
|
try:
|
|
if field.is_required():
|
|
required.append(field_name)
|
|
except (AttributeError, TypeError):
|
|
# Fallback if is_required() doesn't exist or fails
|
|
try:
|
|
has_default = getattr(field, "default", None) is not None
|
|
has_factory = getattr(field, "default_factory", None) is not None
|
|
if not (has_default or has_factory):
|
|
required.append(field_name)
|
|
except Exception:
|
|
# Ultimate fallback - assume required if we can't determine
|
|
logger.debug(
|
|
f"Could not determine if field {field_name} is required, assuming optional"
|
|
)
|
|
|
|
# Create the input schema with explicit properties and required fields
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": parameters,
|
|
}
|
|
|
|
# Only include required field if we have required parameters
|
|
if required:
|
|
input_schema["required"] = required
|
|
|
|
# Add annotations based on tool metadata
|
|
annotations = {}
|
|
|
|
# Use tool name as title if available
|
|
annotations["title"] = getattr(tool.definition, "title", str(name).replace(".", "_"))
|
|
|
|
# Determine hints based on tool properties
|
|
if hasattr(tool.definition, "metadata"):
|
|
metadata = tool.definition.metadata or {}
|
|
annotations["readOnlyHint"] = metadata.get("read_only", False)
|
|
annotations["destructiveHint"] = metadata.get("destructive", False)
|
|
annotations["idempotentHint"] = metadata.get("idempotent", True)
|
|
annotations["openWorldHint"] = metadata.get("open_world", False)
|
|
|
|
# Create the final tool definition
|
|
tool_def: MCPTool = {
|
|
"name": str(name).replace(".", "_"),
|
|
"description": str(description),
|
|
"inputSchema": input_schema,
|
|
"annotations": annotations,
|
|
}
|
|
|
|
logger.debug(f"Created tool definition for {name}")
|
|
|
|
except Exception:
|
|
logger.exception(
|
|
f"Error creating MCP tool definition for {getattr(tool, 'name', str(tool))}"
|
|
)
|
|
return None
|
|
return tool_def
|
|
|
|
|
|
def convert_to_mcp_content(value: Any) -> list[dict[str, Any]]:
|
|
"""
|
|
Convert a Python value to MCP-compatible content.
|
|
"""
|
|
if value is None:
|
|
return []
|
|
|
|
if isinstance(value, (str, bool, int, float)):
|
|
return [{"type": "text", "text": str(value)}]
|
|
|
|
if isinstance(value, (dict, list)):
|
|
return [{"type": "text", "text": json.dumps(value)}]
|
|
|
|
# Default fallback
|
|
return [{"type": "text", "text": str(value)}]
|
|
|
|
|
|
def _map_type_to_json_schema_type(val_type: str) -> str:
|
|
"""
|
|
Map Arcade value types to JSON schema types.
|
|
|
|
Args:
|
|
val_type: The Arcade value type as a string.
|
|
|
|
Returns:
|
|
The corresponding JSON schema type as a string.
|
|
"""
|
|
mapping: dict[str, str] = {
|
|
"string": "string",
|
|
"integer": "integer",
|
|
"number": "number",
|
|
"boolean": "boolean",
|
|
"json": "object",
|
|
"array": "array",
|
|
}
|
|
return mapping.get(val_type, "string")
|