arcade-mcp/libs/arcade-serve/arcade_serve/mcp/convert.py
Sam Partee b6b4cd0a4c
🏗️ Restructure: Multi-Package Architecture + uv Migration (#412)
### Overview
Major restructuring from monolithic `arcade-ai` package to modular
library architecture with standardized uv-based dependency management.

![arcade-ai Monorepo
(2)](https://github.com/user-attachments/assets/25f102b0-bb87-4a04-9701-d227d05664b1)

### 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>
2025-06-11 16:48:17 -07:00

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")