Add Tool Metadata (#766)
This commit is contained in:
parent
b928f52445
commit
a918eef037
24 changed files with 1470 additions and 270 deletions
|
|
@ -4,12 +4,12 @@ version = "0.1.0"
|
|||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"arcade-mcp-server>=1.16.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"arcade-mcp[all]>=1.10.0,<2.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import MCPApp
|
||||
from arcade_mcp_server.metadata import (
|
||||
Behavior,
|
||||
Operation,
|
||||
ToolMetadata,
|
||||
)
|
||||
|
||||
app = MCPApp("EchoServer")
|
||||
|
||||
|
||||
@app.tool
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.READ],
|
||||
read_only=True,
|
||||
destructive=False,
|
||||
idempotent=True,
|
||||
open_world=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def echo(message: Annotated[str, "The message to echo"]) -> str:
|
||||
"""Echo a message back to the caller."""
|
||||
return message
|
||||
|
|
|
|||
84
examples/mcp_servers/tool_metadata/README.md
Normal file
84
examples/mcp_servers/tool_metadata/README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Tool Metadata Example
|
||||
|
||||
This example demonstrates how to use **tool metadata** to describe your tools' classification, behavior, and custom properties.
|
||||
|
||||
## What is Tool Metadata?
|
||||
|
||||
Tool metadata provides structured information about what a tool does:
|
||||
|
||||
| Field | Purpose | Used For |
|
||||
|-------|---------|----------|
|
||||
| **Classification** | What type of service the tool interfaces with | Tool discovery & selection boosting |
|
||||
| **Behavior** | What effects the tool has | Policy decisions, MCP annotations |
|
||||
| **Extras** | Arbitrary key/values | Custom logic (routing, rate limits, etc.) |
|
||||
|
||||
## Classification
|
||||
|
||||
Describes *what type of service* the tool interfaces with.
|
||||
|
||||
```python
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.EMAIL], # What type of service?
|
||||
)
|
||||
```
|
||||
|
||||
**Service Domains** (what type of service): `EMAIL`, `CRM`, `MESSAGING`, `DOCUMENTS`, `CLOUD_STORAGE`, `SOURCE_CODE`, `PAYMENTS`, `SOCIAL_MEDIA`, etc.
|
||||
|
||||
For tools with no external service (`open_world=False`), classification is `None`.
|
||||
|
||||
## Behavior
|
||||
|
||||
Describes the tool's *effects* and maps to MCP annotations.
|
||||
|
||||
```python
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE], # What effect? READ, CREATE, UPDATE, DELETE, OPAQUE
|
||||
read_only=False, # Does it only read data?
|
||||
destructive=False, # Can it cause irreversible data loss?
|
||||
idempotent=True, # Are repeated calls safe?
|
||||
open_world=False, # Does it interact with external systems?
|
||||
)
|
||||
```
|
||||
|
||||
These values become MCP `annotations` that clients like Claude can use to make informed decisions.
|
||||
|
||||
## Extras
|
||||
|
||||
Arbitrary key/values for custom logic that *don't* affect tool selection.
|
||||
|
||||
```python
|
||||
extras={
|
||||
"billing_tier": "free",
|
||||
"max_requests_per_minute": 100,
|
||||
"data_classification": "internal",
|
||||
}
|
||||
```
|
||||
|
||||
Use extras for: IDP routing, feature flags, rate limiting hints, compliance metadata.
|
||||
|
||||
## Running the Example
|
||||
|
||||
```bash
|
||||
cd examples/mcp_servers/tool_metadata
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run with stdio transport
|
||||
uv run src/tool_metadata/server.py stdio
|
||||
|
||||
# Or run with HTTP transport
|
||||
uv run src/tool_metadata/server.py http
|
||||
```
|
||||
|
||||
## Tools in This Example
|
||||
|
||||
| Tool | Operations | Behavior | Notes |
|
||||
|------|------------|----------|-------|
|
||||
| `reverse_text` | READ | read_only, idempotent | Pure computation |
|
||||
| `search_notes` | READ | read_only, idempotent | Query data |
|
||||
| `create_note` | CREATE | not idempotent | Creates new data |
|
||||
| `update_note` | UPDATE | idempotent | Modifies existing data |
|
||||
| `delete_note` | DELETE | destructive, idempotent | Removes data permanently |
|
||||
| `get_notes_stats` | READ | read_only | Has `extras` for custom metadata |
|
||||
| `upsert_note` | CREATE, UPDATE | idempotent | Multi-operation compound action |
|
||||
44
examples/mcp_servers/tool_metadata/pyproject.toml
Normal file
44
examples/mcp_servers/tool_metadata/pyproject.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[project]
|
||||
name = "tool_metadata"
|
||||
version = "0.1.0"
|
||||
description = "Example MCP Server demonstrating tool metadata (classification, behavior, extras)"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.17.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.10.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "tool_metadata"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/tool_metadata"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -0,0 +1 @@
|
|||
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"
|
||||
215
examples/mcp_servers/tool_metadata/src/tool_metadata/server.py
Normal file
215
examples/mcp_servers/tool_metadata/src/tool_metadata/server.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tool Metadata Example MCP Server
|
||||
|
||||
This example demonstrates how to use tool metadata to describe your tools'
|
||||
classification, behavior, and custom properties. Tool metadata helps with:
|
||||
|
||||
- Tool discovery and selection (classification)
|
||||
- Policy decisions and MCP annotations (behavior)
|
||||
- Custom logic like routing or feature flags (extras)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import MCPApp
|
||||
from arcade_mcp_server.metadata import (
|
||||
Behavior,
|
||||
Operation,
|
||||
ToolMetadata,
|
||||
)
|
||||
|
||||
app = MCPApp(name="ToolMetadataDemo", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
# In-memory storage for demo purposes
|
||||
_notes: dict[str, str] = {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 1: Pure computation tool (read-only, no external service)
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.READ],
|
||||
read_only=True,
|
||||
destructive=False,
|
||||
idempotent=True,
|
||||
open_world=False, # No external systems
|
||||
),
|
||||
),
|
||||
)
|
||||
def reverse_text(text: Annotated[str, "The text to reverse"]) -> str:
|
||||
"""Reverse the characters in a string. A pure computation with no side effects."""
|
||||
return text[::-1]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 2: Read-only search tool
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.READ],
|
||||
read_only=True,
|
||||
destructive=False,
|
||||
idempotent=True,
|
||||
open_world=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def search_notes(
|
||||
query: Annotated[str, "Search term to find in note titles and content"],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Search through stored notes by title or content."""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
for title, content in _notes.items():
|
||||
if query_lower in title.lower() or query_lower in content.lower():
|
||||
results.append({"title": title, "content": content})
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 3: Create tool (mutating, not destructive)
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False, # Creating is not destructive
|
||||
idempotent=False, # Creating twice may have different effects
|
||||
open_world=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def create_note(
|
||||
title: Annotated[str, "The title of the note"],
|
||||
content: Annotated[str, "The content of the note"],
|
||||
) -> dict[str, str]:
|
||||
"""Create a new note. Fails if a note with the same title already exists."""
|
||||
if title in _notes:
|
||||
return {"error": f"Note '{title}' already exists. Use update_note instead."}
|
||||
_notes[title] = content
|
||||
return {"status": "created", "title": title}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 4: Update tool (mutating, idempotent)
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.UPDATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
idempotent=True, # Updating with same content is idempotent
|
||||
open_world=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def update_note(
|
||||
title: Annotated[str, "The title of the note to update"],
|
||||
content: Annotated[str, "The new content for the note"],
|
||||
) -> dict[str, str]:
|
||||
"""Update an existing note's content."""
|
||||
if title not in _notes:
|
||||
return {"error": f"Note '{title}' not found. Use create_note first."}
|
||||
_notes[title] = content
|
||||
return {"status": "updated", "title": title}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 5: Delete tool (destructive!)
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.DELETE],
|
||||
read_only=False,
|
||||
destructive=True, # Deletion is destructive - data is lost
|
||||
idempotent=True, # Deleting twice has same effect as once
|
||||
open_world=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def delete_note(
|
||||
title: Annotated[str, "The title of the note to delete"],
|
||||
) -> dict[str, str]:
|
||||
"""Permanently delete a note. This action cannot be undone."""
|
||||
if title not in _notes:
|
||||
return {"error": f"Note '{title}' not found."}
|
||||
del _notes[title]
|
||||
return {"status": "deleted", "title": title}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 6: Tool with extras for custom logic
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.READ],
|
||||
read_only=True,
|
||||
destructive=False,
|
||||
idempotent=True,
|
||||
open_world=False,
|
||||
),
|
||||
# Extras: arbitrary key/values for custom logic
|
||||
# These don't affect tool selection, but can be used for:
|
||||
# - Routing decisions (e.g., which IDP to use)
|
||||
# - Feature flags
|
||||
# - Rate limiting
|
||||
# - Governance/compliance metadata
|
||||
extras={
|
||||
"billing_tier": "free", # Feature flag for billing
|
||||
"max_requests_per_minute": 100, # Rate limiting hint
|
||||
"data_classification": "internal", # Compliance metadata
|
||||
"cache_ttl_seconds": 60, # Caching hint
|
||||
},
|
||||
),
|
||||
)
|
||||
def get_notes_stats() -> dict[str, int]:
|
||||
"""Get statistics about stored notes. Demonstrates the 'extras' field."""
|
||||
total_notes = len(_notes)
|
||||
total_chars = sum(len(content) for content in _notes.values())
|
||||
return {
|
||||
"total_notes": total_notes,
|
||||
"total_characters": total_chars,
|
||||
"average_length": total_chars // total_notes if total_notes > 0 else 0,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 7: Multi-operation tool (upsert = CREATE + UPDATE)
|
||||
# =============================================================================
|
||||
@app.tool(
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[
|
||||
Operation.CREATE,
|
||||
Operation.UPDATE,
|
||||
], # Multiple operations for compound actions
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
idempotent=True, # Upsert is idempotent
|
||||
open_world=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def upsert_note(
|
||||
title: Annotated[str, "The title of the note"],
|
||||
content: Annotated[str, "The content of the note"],
|
||||
) -> dict[str, str]:
|
||||
"""Create or update a note. If the note exists, it will be updated."""
|
||||
action = "updated" if title in _notes else "created"
|
||||
_notes[title] = content
|
||||
return {"status": action, "title": title}
|
||||
|
||||
|
||||
# Run with specific transport
|
||||
if __name__ == "__main__":
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
|
||||
app.run(transport=transport, host="127.0.0.1", port=8000)
|
||||
|
|
@ -19,14 +19,14 @@ try:
|
|||
ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]")
|
||||
ARCADE_MCP_MIN_VERSION = "1.5.8" # Default version if unable to fetch
|
||||
ARCADE_MCP_MIN_VERSION = "1.10.0" # Default version if unable to fetch
|
||||
ARCADE_MCP_MAX_VERSION = "2.0.0"
|
||||
|
||||
ARCADE_TDK_MIN_VERSION = "3.2.2"
|
||||
ARCADE_TDK_MIN_VERSION = "3.6.0"
|
||||
ARCADE_TDK_MAX_VERSION = "4.0.0"
|
||||
ARCADE_SERVE_MIN_VERSION = "3.1.5"
|
||||
ARCADE_SERVE_MAX_VERSION = "4.0.0"
|
||||
ARCADE_MCP_SERVER_MIN_VERSION = "1.11.1"
|
||||
ARCADE_MCP_SERVER_MIN_VERSION = "1.17.0"
|
||||
ARCADE_MCP_SERVER_MAX_VERSION = "2.0.0"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ from typing import Annotated
|
|||
import httpx
|
||||
from arcade_mcp_server import Context, MCPApp
|
||||
from arcade_mcp_server.auth import Reddit
|
||||
from arcade_mcp_server.metadata import (
|
||||
Behavior,
|
||||
Classification,
|
||||
Operation,
|
||||
ServiceDomain,
|
||||
ToolMetadata,
|
||||
)
|
||||
|
||||
app = MCPApp(name="{{ toolkit_name }}", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
|
|
@ -33,7 +40,21 @@ def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of
|
|||
|
||||
# To use this tool locally, you need to install the Arcade CLI (uv tool install arcade-mcp)
|
||||
# and then run 'arcade login' to authenticate.
|
||||
@app.tool(requires_auth=Reddit(scopes=["read"]))
|
||||
@app.tool(
|
||||
requires_auth=Reddit(scopes=["read"]),
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.SOCIAL_MEDIA],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.READ],
|
||||
read_only=True,
|
||||
destructive=False,
|
||||
idempotent=True,
|
||||
open_world=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
async def get_posts_in_subreddit(
|
||||
context: Context, subreddit: Annotated[str, "The name of the subreddit"]
|
||||
) -> dict:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from arcade_core.errors import (
|
|||
ToolkitLoadError,
|
||||
ToolOutputSchemaError,
|
||||
)
|
||||
from arcade_core.metadata import ToolMetadata
|
||||
from arcade_core.schema import (
|
||||
TOOL_NAME_SEPARATOR,
|
||||
FullyQualifiedName,
|
||||
|
|
@ -464,6 +465,15 @@ class ToolCatalog(BaseModel):
|
|||
tool_name = snake_to_pascal_case(raw_tool_name)
|
||||
fully_qualified_name = FullyQualifiedName.from_toolkit(tool_name, toolkit_definition)
|
||||
deprecation_message = getattr(tool, "__tool_deprecation_message__", None)
|
||||
tool_metadata = getattr(tool, "__tool_metadata__", None)
|
||||
|
||||
if tool_metadata is not None:
|
||||
if not isinstance(tool_metadata, ToolMetadata):
|
||||
raise ToolDefinitionError(
|
||||
f"Expected a ToolMetadata instance for 'metadata', "
|
||||
f"but got {type(tool_metadata).__name__}. "
|
||||
)
|
||||
tool_metadata.validate_for_tool()
|
||||
|
||||
return ToolDefinition(
|
||||
name=tool_name,
|
||||
|
|
@ -478,6 +488,7 @@ class ToolCatalog(BaseModel):
|
|||
metadata=metadata_requirement,
|
||||
),
|
||||
deprecation_message=deprecation_message,
|
||||
metadata=tool_metadata,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
337
libs/arcade-core/arcade_core/metadata.py
Normal file
337
libs/arcade-core/arcade_core/metadata.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
"""
|
||||
Tool Metadata
|
||||
|
||||
Defines the metadata model for Arcade tools. This module provides three layers:
|
||||
|
||||
- Classification: What type of service the tool interfaces with (ServiceDomain).
|
||||
Used for tool discovery and search boosting.
|
||||
|
||||
- Behavior: What effects the tool has (operations, MCP-aligned flags).
|
||||
MCP Annotations are computed from this.
|
||||
Commonly used for policy decisions (HITL gates, retry logic, etc.)
|
||||
|
||||
- Extras: Arbitrary key/values for custom logic (IDP routing, feature flags, etc.)
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from arcade_core.errors import ToolDefinitionError
|
||||
|
||||
|
||||
class ServiceDomain(str, Enum):
|
||||
"""
|
||||
The type of service a tool interfaces with.
|
||||
|
||||
Classifies the target service whose data or functionality the tool provides
|
||||
access to -- not the infrastructure used to access it.
|
||||
|
||||
Assignment is based on how the service self-identifies and is broadly
|
||||
recognized in its market. For tools that interact with no external service
|
||||
(open_world=False), ServiceDomain is None..
|
||||
"""
|
||||
|
||||
PROJECT_MANAGEMENT = "project_management"
|
||||
"""Project tracking, issue management, and work item software."""
|
||||
|
||||
CRM = "crm"
|
||||
"""Customer relationship management - contacts, deals, pipelines, sales activities."""
|
||||
|
||||
EMAIL = "email"
|
||||
"""Email services for sending, receiving, and managing messages."""
|
||||
|
||||
CALENDAR = "calendar"
|
||||
"""Calendar and scheduling services."""
|
||||
|
||||
MESSAGING = "messaging"
|
||||
"""Real-time team and business messaging platforms."""
|
||||
|
||||
DOCUMENTS = "documents"
|
||||
"""Document editing, wikis, and knowledge base platforms."""
|
||||
|
||||
CLOUD_STORAGE = "cloud_storage"
|
||||
"""Cloud file storage and sharing services."""
|
||||
|
||||
SPREADSHEETS = "spreadsheets"
|
||||
"""Spreadsheet and tabular data software."""
|
||||
|
||||
PRESENTATIONS = "presentations"
|
||||
"""Presentation and slideshow software."""
|
||||
|
||||
DESIGN = "design"
|
||||
"""UI/UX design and prototyping tools."""
|
||||
|
||||
SOURCE_CODE = "source_code"
|
||||
"""Source code management, version control, and code review."""
|
||||
|
||||
PAYMENTS = "payments"
|
||||
"""Payment processing, invoicing, and billing."""
|
||||
|
||||
SOCIAL_MEDIA = "social_media"
|
||||
"""Platforms where users publish content to a public audience through a social feed."""
|
||||
|
||||
VIDEO_HOSTING = "video_hosting"
|
||||
"""Video hosting, streaming, and distribution platforms."""
|
||||
|
||||
MUSIC_STREAMING = "music_streaming"
|
||||
"""Music streaming and playback platforms."""
|
||||
|
||||
CUSTOMER_SUPPORT = "customer_support"
|
||||
"""Help desk, ticketing, and customer service software."""
|
||||
|
||||
ECOMMERCE = "ecommerce"
|
||||
"""Online shopping, product catalogs, and retail platforms."""
|
||||
|
||||
INCIDENT_MANAGEMENT = "incident_management"
|
||||
"""Incident response, on-call management, and operational alerting."""
|
||||
|
||||
WEB_SCRAPING = "web_scraping"
|
||||
"""Web data extraction and crawling services."""
|
||||
|
||||
CODE_SANDBOX = "code_sandbox"
|
||||
"""Cloud code execution and sandboxed runtime environments."""
|
||||
|
||||
VIDEO_CONFERENCING = "video_conferencing"
|
||||
"""Video meeting and conferencing platforms."""
|
||||
|
||||
GEOSPATIAL = "geospatial"
|
||||
"""Maps, navigation, directions, and geocoding services."""
|
||||
|
||||
FINANCIAL_DATA = "financial_data"
|
||||
"""Financial market data and stock information services."""
|
||||
|
||||
TRAVEL = "travel"
|
||||
"""Travel search, flight and hotel booking platforms."""
|
||||
|
||||
|
||||
class Operation(str, Enum):
|
||||
"""
|
||||
Classifies the tool's effect on resources in the target system.
|
||||
|
||||
The concrete values represent the four fundamental resource lifecycle
|
||||
operations (read, create, update, delete). OPAQUE indicates the effect
|
||||
cannot be determined from the tool's definition because it depends
|
||||
on runtime inputs such as "ExecuteBashCommand(command="...")".
|
||||
|
||||
Can be used for policy decisions (e.g., "require human approval for DELETE tools").
|
||||
"""
|
||||
|
||||
READ = "read"
|
||||
"""
|
||||
Observes resources without changing state in the target system.
|
||||
|
||||
When to use: Any operation that only returns information -- fetching records,
|
||||
searching, listing resources, watching/subscribing to events, validating data,
|
||||
dry-run previews. Tools with only READ should have read_only=True.
|
||||
"""
|
||||
|
||||
CREATE = "create"
|
||||
"""
|
||||
Brings a new resource or record into existence.
|
||||
|
||||
When to use: Inserting new records, uploading files, provisioning resources,
|
||||
scheduling jobs, posting messages, sending emails, instantiating new entities.
|
||||
The resource did not exist before the operation.
|
||||
"""
|
||||
|
||||
UPDATE = "update"
|
||||
"""
|
||||
Modifies an existing resource's state, permissions, metadata, or content.
|
||||
|
||||
When to use: Editing records, changing configuration, renaming, archiving/restoring,
|
||||
patching, associating/disassociating resources (linking), changing lifecycle state
|
||||
(start/stop/pause), sharing resources, modifying access permissions.
|
||||
The resource identity persists after the operation.
|
||||
"""
|
||||
|
||||
DELETE = "delete"
|
||||
"""
|
||||
Removes a resource or record from the system.
|
||||
|
||||
When to use: Permanent deletion, soft-delete where resource becomes inaccessible,
|
||||
canceling queued jobs, unsubscribing, removing files. Use when the resource is
|
||||
no longer retrievable through normal operations. Tools with DELETE should have
|
||||
destructive=True.
|
||||
"""
|
||||
|
||||
OPAQUE = "opaque"
|
||||
"""
|
||||
Effect cannot be determined from the tool's definition because behavior
|
||||
depends entirely on runtime inputs.
|
||||
|
||||
When to use: Tools like Bash.ExecuteCommand(command="...") or E2b.RunCode(code="...")
|
||||
where the actual operation is unknowable at definition time. OPAQUE signals to
|
||||
policy engines that this tool's effects are indeterminate and should be treated
|
||||
with caution.
|
||||
"""
|
||||
|
||||
|
||||
# Operation categories for validation
|
||||
_READ_ONLY_OPERATIONS = {Operation.READ}
|
||||
_MUTATING_OPERATIONS = {Operation.CREATE, Operation.UPDATE, Operation.DELETE}
|
||||
_INDETERMINATE_OPERATIONS = {Operation.OPAQUE}
|
||||
|
||||
|
||||
class Classification(BaseModel):
|
||||
"""
|
||||
What type of service does this tool interface with?
|
||||
|
||||
Used for tool discovery and search boosting.
|
||||
|
||||
Examples:
|
||||
Classification(service_domains=[ServiceDomain.EMAIL])
|
||||
Classification(service_domains=[ServiceDomain.CLOUD_STORAGE, ServiceDomain.DOCUMENTS])
|
||||
"""
|
||||
|
||||
service_domains: list[ServiceDomain] | None = None
|
||||
"""The service category/categories the tool's backing service belongs to. Multi-select."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class Behavior(BaseModel):
|
||||
"""
|
||||
What effects does the tool have? Arcade's data model for tool behavior.
|
||||
|
||||
When using MCP, Behavior is projected to MCP annotations:
|
||||
- read_only -> readOnlyHint
|
||||
- destructive -> destructiveHint
|
||||
- idempotent -> idempotentHint
|
||||
- open_world -> openWorldHint
|
||||
|
||||
Operations classify the tool's effect on resources and can be used for
|
||||
policy decisions (e.g., "require human approval for DELETE tools").
|
||||
|
||||
Example:
|
||||
Behavior(
|
||||
operations=[Operation.DELETE],
|
||||
read_only=False,
|
||||
destructive=True, # DELETE should be destructive
|
||||
idempotent=True, # Deleting twice has same effect
|
||||
open_world=True, # Interacts with external system
|
||||
)
|
||||
"""
|
||||
|
||||
operations: list[Operation] | None = None
|
||||
"""The tool's effect on resources in the target system. Multi-select for compound operations."""
|
||||
|
||||
read_only: bool | None = None
|
||||
"""Tool only reads data, no mutations. Maps to MCP readOnlyHint."""
|
||||
|
||||
destructive: bool | None = None
|
||||
"""Tool can cause irreversible data loss. Maps to MCP destructiveHint."""
|
||||
|
||||
idempotent: bool | None = None
|
||||
"""Repeated calls with same input have no additional effect. Maps to MCP idempotentHint."""
|
||||
|
||||
open_world: bool | None = None
|
||||
"""Tool interacts with external systems (not purely in-process). Maps to MCP openWorldHint."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ToolMetadata(BaseModel):
|
||||
"""
|
||||
Container for metadata about a tool.
|
||||
|
||||
- classification: What type of service does this tool interface with? (for discovery/boosting)
|
||||
- behavior: What effects does it have? (for policy, filtering, MCP annotations)
|
||||
- extras: Arbitrary key/values for custom logic (e.g., IDP routing, feature flags)
|
||||
|
||||
Strict Mode Validation:
|
||||
By default (strict=True), the constructor validates for logical contradictions:
|
||||
- Mutating operations + read_only=True -> Error
|
||||
- OPAQUE operation + read_only=True -> Error
|
||||
- DELETE operation + destructive=False -> Error
|
||||
- ServiceDomain present + open_world=False -> Error
|
||||
|
||||
Set strict=False to bypass validation for valid edge cases (e.g., a "read"
|
||||
tool that increments a view count as a side effect).
|
||||
|
||||
Example:
|
||||
ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.EMAIL],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
idempotent=False,
|
||||
open_world=True,
|
||||
),
|
||||
extras={"idp": "entraID", "requires_mfa": True},
|
||||
)
|
||||
"""
|
||||
|
||||
classification: Classification | None = None
|
||||
"""What type of service the tool interfaces with."""
|
||||
|
||||
behavior: Behavior | None = None
|
||||
"""What effects the tool has."""
|
||||
|
||||
extras: dict[str, Any] | None = None
|
||||
"""Arbitrary key/values for custom logic."""
|
||||
|
||||
strict: bool = Field(default=True, exclude=True)
|
||||
"""Enable validation for logical contradictions. Set False for edge cases.
|
||||
Excluded from serialization - this is a validation-time config flag, not tool metadata."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
def validate_for_tool(self) -> None:
|
||||
"""
|
||||
Validate consistency between behavior and classification.
|
||||
|
||||
Called by the catalog when creating a tool definition.
|
||||
|
||||
Raises:
|
||||
ToolDefinitionError: If strict=True and validation fails
|
||||
"""
|
||||
if not self.strict:
|
||||
return
|
||||
|
||||
behavior = self.behavior
|
||||
classification = self.classification
|
||||
|
||||
if behavior:
|
||||
operations = set(behavior.operations or [])
|
||||
|
||||
# Rule 1: Mutating operations + read_only=True is contradictory
|
||||
mutating_ops = operations & _MUTATING_OPERATIONS
|
||||
if mutating_ops and behavior.read_only is True:
|
||||
raise ToolDefinitionError(
|
||||
f"Tool has the mutating operation(s): "
|
||||
f"'{', '.join([op.value.upper() for op in mutating_ops])}' "
|
||||
f"in its behavior metadata, but is marked read_only=True. "
|
||||
"Fix the contradiction, or set strict=False to bypass."
|
||||
)
|
||||
|
||||
# Rule 2: OPAQUE + read_only=True is contradictory
|
||||
if Operation.OPAQUE in operations and behavior.read_only is True:
|
||||
raise ToolDefinitionError(
|
||||
"Tool has OPAQUE operation but is marked read_only=True. "
|
||||
"Cannot guarantee read-only when the operation is indeterminate. "
|
||||
"Fix the contradiction, or set strict=False to bypass."
|
||||
)
|
||||
|
||||
# Rule 3: DELETE should have destructive=True
|
||||
if Operation.DELETE in operations and behavior.destructive is False:
|
||||
raise ToolDefinitionError(
|
||||
f"Tool has the '{Operation.DELETE.value.upper()}' operation "
|
||||
"but is not marked destructive=True. "
|
||||
"Fix the contradiction, or set strict=False to bypass."
|
||||
)
|
||||
|
||||
if classification and behavior:
|
||||
service_domains = classification.service_domains or []
|
||||
|
||||
# Rule 4: ServiceDomain present implies open_world=True
|
||||
if len(service_domains) > 0 and behavior.open_world is False:
|
||||
raise ToolDefinitionError(
|
||||
"Tool has a ServiceDomain (implying an external service) "
|
||||
"but is marked open_world=False. "
|
||||
"Fix the contradiction, or set strict=False to bypass."
|
||||
)
|
||||
|
|
@ -24,6 +24,7 @@ from typing import Any, Literal, Protocol
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from arcade_core.errors import ErrorKind
|
||||
from arcade_core.metadata import ToolMetadata
|
||||
|
||||
# allow for custom tool name separator
|
||||
TOOL_NAME_SEPARATOR = os.getenv("ARCADE_TOOL_NAME_SEPARATOR", ".")
|
||||
|
|
@ -327,6 +328,9 @@ class ToolDefinition(BaseModel):
|
|||
deprecation_message: str | None = None
|
||||
"""The message to display when the tool is deprecated."""
|
||||
|
||||
metadata: ToolMetadata | None = None
|
||||
"""Metadata about the tool"""
|
||||
|
||||
def get_fully_qualified_name(self) -> FullyQualifiedName:
|
||||
return FullyQualifiedName(self.name, self.toolkit.name, self.toolkit.version)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-core"
|
||||
version = "4.3.0"
|
||||
version = "4.4.0"
|
||||
description = "Arcade Core - Core library for Arcade platform"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import base64
|
||||
import json
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Any, get_args, get_origin
|
||||
from typing import Any
|
||||
|
||||
from arcade_core.catalog import MaterializedTool
|
||||
from arcade_core.schema import ToolDefinition
|
||||
|
|
@ -12,95 +11,83 @@ from arcade_mcp_server.types import MCPContent, MCPTool, TextContent, ToolAnnota
|
|||
logger = logging.getLogger("arcade.mcp")
|
||||
|
||||
|
||||
def create_mcp_tool(tool: MaterializedTool) -> MCPTool | None:
|
||||
def _build_arcade_meta(definition: ToolDefinition) -> dict[str, Any] | None:
|
||||
"""Build the _meta.arcade structure from a tool definition.
|
||||
|
||||
The structure of _meta.arcade mirrors Arcade format when possible.
|
||||
"""
|
||||
Create an MCP-compatible tool definition from an Arcade tool.
|
||||
arcade_meta: dict[str, Any] = {}
|
||||
|
||||
requirements = definition.requirements
|
||||
if requirements.authorization or requirements.secrets or requirements.metadata:
|
||||
arcade_meta["requirements"] = requirements.model_dump(exclude_none=True)
|
||||
|
||||
tool_metadata = definition.metadata
|
||||
if tool_metadata:
|
||||
metadata_dump = tool_metadata.model_dump(mode="json", exclude_none=True)
|
||||
if metadata_dump:
|
||||
arcade_meta["metadata"] = metadata_dump
|
||||
|
||||
return arcade_meta if arcade_meta else None
|
||||
|
||||
|
||||
def create_mcp_tool(materialized_tool: MaterializedTool) -> MCPTool:
|
||||
"""
|
||||
Create an MCP-compatible tool definition from a MaterializedTool.
|
||||
|
||||
Computes MCP annotations from tool metadata behavior fields and builds
|
||||
the ``_meta.arcade`` structure with requirements and metadata.
|
||||
|
||||
Args:
|
||||
tool: An Arcade tool object
|
||||
materialized_tool: A materialized Arcade tool
|
||||
|
||||
Returns:
|
||||
An MCP tool definition or None if the tool cannot be converted
|
||||
An MCP tool definition
|
||||
"""
|
||||
try:
|
||||
# Get the tool name from the definition
|
||||
tool_name = getattr(tool.definition, "name", "unknown")
|
||||
fully_qualified_name = getattr(tool.definition, "fully_qualified_name", None)
|
||||
definition = materialized_tool.definition
|
||||
name = definition.fully_qualified_name.replace(".", "_")
|
||||
|
||||
# Use fully qualified name for MCP tool name (replacing dots with underscores)
|
||||
name = fully_qualified_name.replace(".", "_") if fully_qualified_name else tool_name
|
||||
# Build the tool's description
|
||||
description = definition.description
|
||||
deprecation_msg = getattr(definition, "deprecation_message", None)
|
||||
if deprecation_msg:
|
||||
description = f"[DEPRECATED: {deprecation_msg}] {description}"
|
||||
|
||||
description = getattr(tool.definition, "description", "No description available")
|
||||
# Build the tool's output schema
|
||||
output_schema = None
|
||||
if hasattr(definition, "output") and definition.output:
|
||||
output_def = definition.output
|
||||
if getattr(output_def, "value_schema", None):
|
||||
output_schema = _build_value_schema_json(output_def.value_schema)
|
||||
|
||||
# Check for deprecation
|
||||
deprecation_msg = getattr(tool.definition, "deprecation_message", None)
|
||||
if deprecation_msg:
|
||||
description = f"[DEPRECATED: {deprecation_msg}] {description}"
|
||||
|
||||
# Build input schema using authoritative ToolDefinition when available
|
||||
try:
|
||||
if getattr(tool.definition, "input", None):
|
||||
input_schema = build_input_schema_from_definition(tool.definition)
|
||||
else:
|
||||
# Fallback to input_model if definition input is missing
|
||||
input_schema = _build_input_schema_from_model(tool)
|
||||
except Exception:
|
||||
logger.exception("Error while constructing input schema; proceeding with empty schema")
|
||||
input_schema = {"type": "object", "properties": {}, "additionalProperties": False}
|
||||
|
||||
# Create output schema if available
|
||||
output_schema = None
|
||||
try:
|
||||
if hasattr(tool.definition, "output") and tool.definition.output:
|
||||
output_def = tool.definition.output
|
||||
if getattr(output_def, "value_schema", None):
|
||||
output_schema = _build_value_schema_json(output_def.value_schema)
|
||||
except Exception:
|
||||
logger.exception("Error while constructing output schema; omitting output schema")
|
||||
|
||||
requirements = tool.definition.requirements
|
||||
|
||||
# Build annotations using model for stricter typing
|
||||
# Build MCP tool annotations from metadata behavior fields
|
||||
title = getattr(materialized_tool.tool, "__tool_name__", definition.name)
|
||||
tool_metadata = definition.metadata
|
||||
if tool_metadata and tool_metadata.behavior:
|
||||
behavior = tool_metadata.behavior
|
||||
annotations = ToolAnnotations(
|
||||
readOnlyHint=not (
|
||||
requirements.authorization or requirements.secrets or requirements.metadata
|
||||
),
|
||||
openWorldHint=requirements.authorization is not None,
|
||||
title=title,
|
||||
readOnlyHint=behavior.read_only,
|
||||
destructiveHint=behavior.destructive,
|
||||
idempotentHint=behavior.idempotent,
|
||||
openWorldHint=behavior.open_world,
|
||||
)
|
||||
else:
|
||||
annotations = ToolAnnotations(title=title)
|
||||
|
||||
# Build meta with requirements if any exist
|
||||
meta = None
|
||||
if requirements.authorization or requirements.secrets or requirements.metadata:
|
||||
meta = {"arcade_requirements": requirements.model_dump(exclude_none=True)}
|
||||
# Build _meta.arcade structure
|
||||
arcade_meta = _build_arcade_meta(definition)
|
||||
meta = {"arcade": arcade_meta} if arcade_meta else None
|
||||
|
||||
# Instantiate MCPTool model to ensure shape correctness
|
||||
return MCPTool(
|
||||
name=name,
|
||||
title=tool.definition.toolkit.name + "_" + tool_name,
|
||||
description=str(description),
|
||||
inputSchema=input_schema,
|
||||
outputSchema=output_schema if output_schema else None,
|
||||
annotations=annotations,
|
||||
_meta=meta,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Error creating MCP tool definition for {getattr(tool, 'name', str(tool))}"
|
||||
)
|
||||
try:
|
||||
# Fallback minimal tool to avoid None in callers
|
||||
fallback_name = getattr(tool.definition, "fully_qualified_name", "unknown").replace(
|
||||
".", "_"
|
||||
)
|
||||
return MCPTool(
|
||||
name=fallback_name,
|
||||
title=fallback_name,
|
||||
description="",
|
||||
inputSchema={"type": "object", "properties": {}, "additionalProperties": False},
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return MCPTool(
|
||||
name=name,
|
||||
title=title,
|
||||
description=str(description),
|
||||
inputSchema=build_input_schema_from_definition(definition),
|
||||
outputSchema=output_schema if output_schema else None,
|
||||
annotations=annotations,
|
||||
_meta=meta,
|
||||
)
|
||||
|
||||
|
||||
def convert_to_mcp_content(value: Any) -> list[MCPContent]:
|
||||
|
|
@ -241,116 +228,6 @@ def build_input_schema_from_definition(definition: ToolDefinition) -> dict[str,
|
|||
return input_schema
|
||||
|
||||
|
||||
def _build_input_schema_from_model(tool: MaterializedTool) -> dict[str, Any]:
|
||||
"""Build input schema from a tool's input_model as a fallback."""
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
context_param_name = None
|
||||
tool_input = getattr(tool.definition, "input", None)
|
||||
if tool_input is not None:
|
||||
context_param_name = getattr(tool_input, "tool_context_parameter_name", None)
|
||||
|
||||
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():
|
||||
if field_name == context_param_name:
|
||||
continue
|
||||
|
||||
field_type = getattr(field, "annotation", None)
|
||||
field_type_name = "string" # default
|
||||
|
||||
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 (getattr(field_type, "__origin__", None) is list):
|
||||
field_type_name = "array"
|
||||
elif field_type is dict or (getattr(field_type, "__origin__", None) is dict):
|
||||
field_type_name = "object"
|
||||
|
||||
field_description = getattr(field, "description", None) or f"Parameter: {field_name}"
|
||||
|
||||
param_def: dict[str, Any] = {
|
||||
"type": field_type_name,
|
||||
"description": field_description,
|
||||
}
|
||||
|
||||
# Enum support: Enum classes or typing.Annotated[...] with Enum
|
||||
enum_type = None
|
||||
ann = getattr(field, "annotation", None)
|
||||
if ann is not None:
|
||||
origin = get_origin(ann)
|
||||
args = get_args(ann)
|
||||
# typing.Annotated[Enum, ...]
|
||||
if origin is not None and args:
|
||||
for arg in args:
|
||||
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]
|
||||
|
||||
# Literal[...] support for enum-like constraints
|
||||
if ann is not None and get_origin(ann) is None:
|
||||
pass # no-op, handled above
|
||||
elif ann is not None and get_origin(ann) is Any:
|
||||
pass
|
||||
else:
|
||||
if get_origin(ann) is None:
|
||||
...
|
||||
|
||||
# Attempt to infer inner list item types for list[T]
|
||||
if field_type_name == "array":
|
||||
inner = None
|
||||
if get_origin(field_type) is list and get_args(field_type):
|
||||
inner = get_args(field_type)[0]
|
||||
if inner is int:
|
||||
param_def["items"] = {"type": "integer"}
|
||||
elif inner is float:
|
||||
param_def["items"] = {"type": "number"}
|
||||
elif inner is bool:
|
||||
param_def["items"] = {"type": "boolean"}
|
||||
elif inner is str:
|
||||
param_def["items"] = {"type": "string"}
|
||||
|
||||
properties[field_name] = param_def
|
||||
|
||||
# Required detection with multiple strategies
|
||||
is_required_attr = getattr(field, "is_required", None)
|
||||
try:
|
||||
if callable(is_required_attr):
|
||||
if is_required_attr():
|
||||
required.append(field_name)
|
||||
elif isinstance(is_required_attr, bool) and is_required_attr:
|
||||
required.append(field_name)
|
||||
else:
|
||||
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:
|
||||
logger.debug(
|
||||
f"Could not determine if field {field_name} is required, assuming optional"
|
||||
)
|
||||
|
||||
input_schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if required:
|
||||
input_schema["required"] = required
|
||||
return input_schema
|
||||
|
||||
|
||||
def _build_value_schema_json(value_schema: Any) -> dict[str, Any]:
|
||||
"""Map a ValueSchema to a JSON schema fragment for outputSchema."""
|
||||
schema: dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import TypedDict
|
|||
|
||||
from arcade_core.catalog import MaterializedTool, ToolCatalog
|
||||
|
||||
from arcade_mcp_server.convert import build_input_schema_from_definition
|
||||
from arcade_mcp_server.convert import create_mcp_tool
|
||||
from arcade_mcp_server.exceptions import NotFoundError
|
||||
from arcade_mcp_server.managers.base import ComponentManager
|
||||
from arcade_mcp_server.types import MCPTool
|
||||
|
|
@ -35,20 +35,13 @@ class ToolManager(ComponentManager[Key, ManagedTool]):
|
|||
def _sanitize_name(name: str) -> str:
|
||||
return name.replace(".", "_")
|
||||
|
||||
def _to_dto(self, tool: MaterializedTool) -> MCPTool:
|
||||
# Extract requirements and build meta if needed
|
||||
requirements = tool.definition.requirements
|
||||
meta = None
|
||||
if requirements.authorization or requirements.secrets or requirements.metadata:
|
||||
meta = {"arcade_requirements": requirements.model_dump(exclude_none=True)}
|
||||
@staticmethod
|
||||
def _to_dto(materialized_tool: MaterializedTool) -> MCPTool:
|
||||
"""Convert a MaterializedTool to an MCPTool DTO.
|
||||
|
||||
return MCPTool(
|
||||
name=self._sanitize_name(tool.definition.fully_qualified_name),
|
||||
title=f"{tool.definition.toolkit.name}_{tool.definition.name}",
|
||||
description=tool.definition.description,
|
||||
inputSchema=build_input_schema_from_definition(tool.definition),
|
||||
_meta=meta,
|
||||
)
|
||||
Delegates to :func:`arcade_mcp_server.convert.create_mcp_tool`.
|
||||
"""
|
||||
return create_mcp_tool(materialized_tool)
|
||||
|
||||
async def load_from_catalog(self, catalog: ToolCatalog) -> None:
|
||||
pairs: list[tuple[Key, ManagedTool]] = []
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from types import ModuleType
|
|||
from typing import Any, Callable, Literal, ParamSpec, TypeVar, cast
|
||||
|
||||
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError
|
||||
from arcade_core.metadata import ToolMetadata
|
||||
from arcade_tdk.auth import ToolAuthorization
|
||||
from arcade_tdk.error_adapters import ErrorAdapter
|
||||
from arcade_tdk.tool import tool as tool_decorator
|
||||
|
|
@ -225,6 +226,7 @@ class MCPApp:
|
|||
requires_secrets: list[str] | None = None,
|
||||
requires_metadata: list[str] | None = None,
|
||||
adapters: list[ErrorAdapter] | None = None,
|
||||
metadata: ToolMetadata | None = None,
|
||||
) -> Callable[P, T]:
|
||||
"""Add a tool for build-time materialization (pre-server)."""
|
||||
if not hasattr(func, "__tool_name__"):
|
||||
|
|
@ -236,6 +238,7 @@ class MCPApp:
|
|||
requires_secrets=requires_secrets,
|
||||
requires_metadata=requires_metadata,
|
||||
adapters=adapters,
|
||||
metadata=metadata,
|
||||
)
|
||||
try:
|
||||
self._catalog.add_tool(
|
||||
|
|
@ -264,6 +267,7 @@ class MCPApp:
|
|||
requires_secrets: list[str] | None = None,
|
||||
requires_metadata: list[str] | None = None,
|
||||
adapters: list[ErrorAdapter] | None = None,
|
||||
metadata: ToolMetadata | None = None,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]] | Callable[P, T]:
|
||||
"""Decorator for adding tools with optional parameters."""
|
||||
|
||||
|
|
@ -276,6 +280,7 @@ class MCPApp:
|
|||
requires_secrets=requires_secrets,
|
||||
requires_metadata=requires_metadata,
|
||||
adapters=adapters,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if func is not None:
|
||||
|
|
|
|||
15
libs/arcade-mcp-server/arcade_mcp_server/metadata.py
Normal file
15
libs/arcade-mcp-server/arcade_mcp_server/metadata.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from arcade_core.metadata import (
|
||||
Behavior,
|
||||
Classification,
|
||||
Operation,
|
||||
ServiceDomain,
|
||||
ToolMetadata,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Behavior",
|
||||
"Classification",
|
||||
"Operation",
|
||||
"ServiceDomain",
|
||||
"ToolMetadata",
|
||||
]
|
||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "arcade-mcp-server"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Arcade.dev" }]
|
||||
|
|
@ -21,9 +21,9 @@ classifiers = [
|
|||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-core>=4.3.0,<5.0.0",
|
||||
"arcade-core>=4.4.0,<5.0.0",
|
||||
"arcade-serve>=3.2.0,<4.0.0",
|
||||
"arcade-tdk>=3.4.0,<4.0.0",
|
||||
"arcade-tdk>=3.6.0,<4.0.0",
|
||||
"arcadepy>=1.5.0",
|
||||
"pydantic>=2.0.0",
|
||||
"fastapi>=0.100.0",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import inspect
|
|||
import logging
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from arcade_core.metadata import ToolMetadata
|
||||
|
||||
from arcade_tdk.auth import ToolAuthorization
|
||||
from arcade_tdk.error_adapters import ErrorAdapter
|
||||
from arcade_tdk.error_adapters.utils import get_adapter_for_auth_provider
|
||||
|
|
@ -112,6 +114,7 @@ def tool(
|
|||
requires_secrets: list[str] | None = None,
|
||||
requires_metadata: list[str] | None = None,
|
||||
adapters: list[ErrorAdapter] | None = None,
|
||||
metadata: ToolMetadata | None = None,
|
||||
) -> Callable:
|
||||
def decorator(func: Callable) -> Callable:
|
||||
func_name = str(getattr(func, "__name__", None))
|
||||
|
|
@ -122,6 +125,7 @@ def tool(
|
|||
func.__tool_requires_auth__ = requires_auth # type: ignore[attr-defined]
|
||||
func.__tool_requires_secrets__ = requires_secrets # type: ignore[attr-defined]
|
||||
func.__tool_requires_metadata__ = requires_metadata # type: ignore[attr-defined]
|
||||
func.__tool_metadata__ = metadata # type: ignore[attr-defined]
|
||||
|
||||
adapter_chain = _build_adapter_chain(adapters, requires_auth)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-tdk"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
description = "Arcade TDK - Toolkit Development Kit for building Arcade tools"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
|
@ -16,7 +16,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["arcade-core>=4.3.0,<5.0.0", "pydantic>=2.7.0"]
|
||||
dependencies = ["arcade-core>=4.4.0,<5.0.0", "pydantic>=2.7.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
|
|
|||
|
|
@ -371,50 +371,10 @@ class TestCreateMCPTool:
|
|||
assert schema["properties"] == {}
|
||||
assert schema.get("required", []) in ([], None)
|
||||
|
||||
def test_missing_input_attribute_fallback(self):
|
||||
"""Test tool with missing input attribute to trigger _build_input_schema_from_model fallback."""
|
||||
# Create a valid ToolDefinition first
|
||||
tool_def = ToolDefinition(
|
||||
name="test_fallback",
|
||||
fully_qualified_name="Test.test_fallback",
|
||||
description="Test fallback to input model",
|
||||
toolkit=ToolkitDefinition(name="Test"),
|
||||
input=ToolInput(parameters=[]),
|
||||
output=ToolOutput(),
|
||||
requirements=ToolRequirements(),
|
||||
)
|
||||
def test_output_schema_included(self, materialized_tool):
|
||||
"""Test that output schema is included when definition has one."""
|
||||
mcp_tool = create_mcp_tool(materialized_tool)
|
||||
|
||||
@tool
|
||||
def f(
|
||||
name: Annotated[str, "User name"], age: Annotated[int, "User age"] = 25
|
||||
) -> Annotated[str, "greeting"]:
|
||||
return f"Hello {name}, you are {age} years old"
|
||||
|
||||
input_model, output_model = create_func_models(f)
|
||||
meta = ToolMeta(module=f.__module__, toolkit=tool_def.toolkit.name)
|
||||
mat_tool = MaterializedTool(
|
||||
tool=f,
|
||||
definition=tool_def,
|
||||
meta=meta,
|
||||
input_model=input_model,
|
||||
output_model=output_model,
|
||||
)
|
||||
|
||||
# Remove the input attribute from the definition to simulate the missing attribute case
|
||||
delattr(mat_tool.definition, "input")
|
||||
|
||||
mcp_tool = create_mcp_tool(mat_tool)
|
||||
schema = mcp_tool.inputSchema
|
||||
|
||||
assert schema["type"] == "object"
|
||||
assert "properties" in schema
|
||||
assert "name" in schema["properties"]
|
||||
assert "age" in schema["properties"]
|
||||
|
||||
# Ensure the schema was built from the model and not the definition
|
||||
assert schema["properties"]["name"]["type"] == "string"
|
||||
assert schema["properties"]["age"]["type"] == "integer"
|
||||
|
||||
if "required" in schema:
|
||||
assert "name" in schema["required"]
|
||||
assert "age" not in schema["required"]
|
||||
# The fixture's output has value_schema=ValueSchema(val_type="number")
|
||||
assert mcp_tool.outputSchema is not None
|
||||
assert mcp_tool.outputSchema["type"] == "number"
|
||||
|
|
|
|||
372
libs/tests/arcade_mcp_server/test_tool_metadata_serialization.py
Normal file
372
libs/tests/arcade_mcp_server/test_tool_metadata_serialization.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Tests for tool metadata serialization to MCP format."""
|
||||
|
||||
import pytest
|
||||
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolMeta, create_func_models
|
||||
from arcade_core.metadata import (
|
||||
Behavior,
|
||||
Classification,
|
||||
Operation,
|
||||
ServiceDomain,
|
||||
ToolMetadata,
|
||||
)
|
||||
from arcade_mcp_server.managers.tool import ToolManager
|
||||
from arcade_tdk import tool
|
||||
from arcade_tdk.auth import OAuth2
|
||||
|
||||
|
||||
class TestToolMetadataSerialization:
|
||||
"""Test serialization of ToolMetadata to MCP format."""
|
||||
|
||||
@pytest.fixture
|
||||
def tool_manager(self) -> ToolManager:
|
||||
return ToolManager()
|
||||
|
||||
def _create_materialized_tool(self, tool_func) -> MaterializedTool:
|
||||
"""Helper to create a MaterializedTool from a decorated function."""
|
||||
definition = ToolCatalog.create_tool_definition(
|
||||
tool_func, toolkit_name="Test", toolkit_version="1.0.0"
|
||||
)
|
||||
input_model, output_model = create_func_models(tool_func)
|
||||
return MaterializedTool(
|
||||
tool=tool_func,
|
||||
definition=definition,
|
||||
meta=ToolMeta(module="test"),
|
||||
input_model=input_model,
|
||||
output_model=output_model,
|
||||
)
|
||||
|
||||
def test_annotations_computed_from_behavior(self, tool_manager: ToolManager):
|
||||
"""Annotations should be computed from behavior fields."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
idempotent=True,
|
||||
open_world=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
def create_item() -> str:
|
||||
"""Create an item."""
|
||||
return "created"
|
||||
|
||||
materialized = self._create_materialized_tool(create_item)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
assert dto.annotations is not None
|
||||
assert dto.annotations.title == "CreateItem"
|
||||
assert dto.annotations.readOnlyHint is False
|
||||
assert dto.annotations.destructiveHint is False
|
||||
assert dto.annotations.idempotentHint is True
|
||||
assert dto.annotations.openWorldHint is True
|
||||
|
||||
def test_meta_arcade_includes_classification(self, tool_manager: ToolManager):
|
||||
"""_meta.arcade.metadata should include classification with service_domains."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.MESSAGING, ServiceDomain.DOCUMENTS],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
open_world=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
def forward_message() -> str:
|
||||
"""Forward a message."""
|
||||
return "forwarded"
|
||||
|
||||
materialized = self._create_materialized_tool(forward_message)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "metadata" in dto.meta["arcade"]
|
||||
assert "classification" in dto.meta["arcade"]["metadata"]
|
||||
assert dto.meta["arcade"]["metadata"]["classification"]["service_domains"] == [
|
||||
"messaging",
|
||||
"documents",
|
||||
]
|
||||
|
||||
def test_meta_arcade_includes_operations(self, tool_manager: ToolManager):
|
||||
"""_meta.arcade.metadata.behavior should include operations as lowercase strings."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.CREATE, Operation.UPDATE]),
|
||||
),
|
||||
)
|
||||
def upsert_record() -> str:
|
||||
"""Upsert a record."""
|
||||
return "upserted"
|
||||
|
||||
materialized = self._create_materialized_tool(upsert_record)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "metadata" in dto.meta["arcade"]
|
||||
assert "behavior" in dto.meta["arcade"]["metadata"]
|
||||
assert dto.meta["arcade"]["metadata"]["behavior"]["operations"] == ["create", "update"]
|
||||
|
||||
def test_meta_arcade_includes_extras(self, tool_manager: ToolManager):
|
||||
"""_meta.arcade.metadata should include extras dict unchanged."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
extras={"idp": "entraID", "requires_mfa": True, "max_requests": 100},
|
||||
),
|
||||
)
|
||||
def secure_action() -> str:
|
||||
"""Perform secure action."""
|
||||
return "done"
|
||||
|
||||
materialized = self._create_materialized_tool(secure_action)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "metadata" in dto.meta["arcade"]
|
||||
assert "extras" in dto.meta["arcade"]["metadata"]
|
||||
assert dto.meta["arcade"]["metadata"]["extras"] == {
|
||||
"idp": "entraID",
|
||||
"requires_mfa": True,
|
||||
"max_requests": 100,
|
||||
}
|
||||
|
||||
def test_tool_without_metadata_still_works(self, tool_manager: ToolManager):
|
||||
"""Tools without metadata should still serialize correctly with title."""
|
||||
|
||||
@tool(desc="Test tool")
|
||||
def simple_tool() -> str:
|
||||
"""Simple tool."""
|
||||
return "simple"
|
||||
|
||||
materialized = self._create_materialized_tool(simple_tool)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# Should have title in annotations even without behavior
|
||||
assert dto.annotations is not None
|
||||
assert dto.annotations.title == "SimpleTool"
|
||||
# Hint fields should be None
|
||||
assert dto.annotations.readOnlyHint is None
|
||||
assert dto.annotations.destructiveHint is None
|
||||
assert dto.annotations.idempotentHint is None
|
||||
assert dto.annotations.openWorldHint is None
|
||||
# Should not have arcade meta without metadata
|
||||
assert dto.meta is None or "arcade" not in dto.meta
|
||||
|
||||
def test_full_metadata_serialization(self, tool_manager: ToolManager):
|
||||
"""Test complete metadata serialization with all fields."""
|
||||
|
||||
@tool(
|
||||
desc="Send an email using the Gmail API",
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.EMAIL],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
idempotent=False,
|
||||
open_world=True,
|
||||
),
|
||||
extras={"idp": "entraID", "requires_mfa": True},
|
||||
),
|
||||
)
|
||||
def send_email() -> str:
|
||||
"""Send an email."""
|
||||
return "sent"
|
||||
|
||||
materialized = self._create_materialized_tool(send_email)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# Verify annotations
|
||||
assert dto.annotations is not None
|
||||
assert dto.annotations.title == "SendEmail"
|
||||
assert dto.annotations.readOnlyHint is False
|
||||
assert dto.annotations.destructiveHint is False
|
||||
assert dto.annotations.idempotentHint is False
|
||||
assert dto.annotations.openWorldHint is True
|
||||
|
||||
# Verify _meta.arcade structure (mirrors Arcade format)
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
arcade = dto.meta["arcade"]
|
||||
|
||||
assert "metadata" in arcade
|
||||
metadata = arcade["metadata"]
|
||||
|
||||
assert metadata["classification"]["service_domains"] == ["email"]
|
||||
assert metadata["behavior"]["operations"] == ["create"]
|
||||
assert metadata["behavior"]["read_only"] is False
|
||||
assert metadata["behavior"]["destructive"] is False
|
||||
assert metadata["behavior"]["idempotent"] is False
|
||||
assert metadata["behavior"]["open_world"] is True
|
||||
assert metadata["extras"] == {"idp": "entraID", "requires_mfa": True}
|
||||
|
||||
def test_metadata_with_only_classification(self, tool_manager: ToolManager):
|
||||
"""Tools with only classification should serialize correctly."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.WEB_SCRAPING],
|
||||
),
|
||||
),
|
||||
)
|
||||
def search_web() -> str:
|
||||
"""Search the web."""
|
||||
return "results"
|
||||
|
||||
materialized = self._create_materialized_tool(search_web)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# Annotations should still have title
|
||||
assert dto.annotations is not None
|
||||
assert dto.annotations.title == "SearchWeb"
|
||||
# Hint fields should be None without behavior
|
||||
assert dto.annotations.readOnlyHint is None
|
||||
|
||||
# _meta.arcade.metadata should have classification but not behavior
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "metadata" in dto.meta["arcade"]
|
||||
assert "classification" in dto.meta["arcade"]["metadata"]
|
||||
assert "behavior" not in dto.meta["arcade"]["metadata"]
|
||||
|
||||
def test_metadata_with_only_extras(self, tool_manager: ToolManager):
|
||||
"""Tools with only extras should serialize correctly."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
extras={"custom_key": "custom_value"},
|
||||
),
|
||||
)
|
||||
def custom_tool() -> str:
|
||||
"""Custom tool."""
|
||||
return "custom"
|
||||
|
||||
materialized = self._create_materialized_tool(custom_tool)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# _meta.arcade.metadata should have only extras
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "metadata" in dto.meta["arcade"]
|
||||
assert "classification" not in dto.meta["arcade"]["metadata"]
|
||||
assert "behavior" not in dto.meta["arcade"]["metadata"]
|
||||
assert dto.meta["arcade"]["metadata"]["extras"] == {"custom_key": "custom_value"}
|
||||
|
||||
def test_meta_arcade_includes_requirements(self, tool_manager: ToolManager):
|
||||
"""_meta.arcade should include requirements when tool has auth."""
|
||||
|
||||
@tool(
|
||||
desc="Tool requiring OAuth",
|
||||
requires_auth=OAuth2(
|
||||
id="google",
|
||||
scopes=["https://www.googleapis.com/auth/gmail.send"],
|
||||
),
|
||||
)
|
||||
def authenticated_tool() -> str:
|
||||
"""Tool requiring authentication."""
|
||||
return "authenticated"
|
||||
|
||||
materialized = self._create_materialized_tool(authenticated_tool)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# _meta.arcade should have requirements
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "requirements" in dto.meta["arcade"]
|
||||
assert "authorization" in dto.meta["arcade"]["requirements"]
|
||||
assert dto.meta["arcade"]["requirements"]["authorization"]["id"] == "google"
|
||||
|
||||
def test_meta_arcade_includes_secrets_requirements(self, tool_manager: ToolManager):
|
||||
"""_meta.arcade should include requirements when tool has secrets."""
|
||||
|
||||
@tool(
|
||||
desc="Tool requiring secrets",
|
||||
requires_secrets=["API_KEY", "API_SECRET"],
|
||||
)
|
||||
def secret_tool() -> str:
|
||||
"""Tool requiring secrets."""
|
||||
return "secret"
|
||||
|
||||
materialized = self._create_materialized_tool(secret_tool)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# _meta.arcade should have requirements
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
assert "requirements" in dto.meta["arcade"]
|
||||
assert "secrets" in dto.meta["arcade"]["requirements"]
|
||||
secrets_req = dto.meta["arcade"]["requirements"]["secrets"]
|
||||
assert "API_KEY" in [s["key"] for s in secrets_req]
|
||||
assert "API_SECRET" in [s["key"] for s in secrets_req]
|
||||
|
||||
def test_full_metadata_with_requirements(self, tool_manager: ToolManager):
|
||||
"""Test complete serialization with both metadata and requirements."""
|
||||
|
||||
@tool(
|
||||
desc="Full featured tool",
|
||||
requires_auth=OAuth2(
|
||||
id="google",
|
||||
scopes=["https://www.googleapis.com/auth/gmail.send"],
|
||||
),
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.EMAIL],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
open_world=True,
|
||||
),
|
||||
extras={"idp": "google"},
|
||||
),
|
||||
)
|
||||
def full_tool() -> str:
|
||||
"""Full featured tool."""
|
||||
return "full"
|
||||
|
||||
materialized = self._create_materialized_tool(full_tool)
|
||||
dto = tool_manager._to_dto(materialized)
|
||||
|
||||
# Verify structure: requirements at top level, metadata container for rest
|
||||
assert dto.meta is not None
|
||||
assert "arcade" in dto.meta
|
||||
arcade = dto.meta["arcade"]
|
||||
|
||||
# Requirements at top level of arcade
|
||||
assert "requirements" in arcade
|
||||
assert arcade["requirements"]["authorization"]["id"] == "google"
|
||||
|
||||
# metadata container holds classification, behavior, extras
|
||||
assert "metadata" in arcade
|
||||
metadata = arcade["metadata"]
|
||||
|
||||
assert "classification" in metadata
|
||||
assert "behavior" in metadata
|
||||
assert "extras" in metadata
|
||||
|
||||
# Verify specific values
|
||||
assert metadata["classification"]["service_domains"] == ["email"]
|
||||
assert metadata["behavior"]["operations"] == ["create"]
|
||||
assert metadata["behavior"]["read_only"] is False
|
||||
assert metadata["behavior"]["destructive"] is False
|
||||
assert metadata["behavior"]["open_world"] is True
|
||||
assert metadata["extras"] == {"idp": "google"}
|
||||
248
libs/tests/tool/test_tool_metadata.py
Normal file
248
libs/tests/tool/test_tool_metadata.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import pytest
|
||||
from arcade_core.catalog import ToolCatalog
|
||||
from arcade_core.errors import ToolDefinitionError
|
||||
from arcade_core.metadata import (
|
||||
_INDETERMINATE_OPERATIONS,
|
||||
_MUTATING_OPERATIONS,
|
||||
_READ_ONLY_OPERATIONS,
|
||||
Behavior,
|
||||
Classification,
|
||||
Operation,
|
||||
ServiceDomain,
|
||||
ToolMetadata,
|
||||
)
|
||||
from arcade_tdk import tool
|
||||
|
||||
|
||||
class TestEnumCoverage:
|
||||
"""
|
||||
Tests to ensure all enum values are accounted for in validation helper sets.
|
||||
|
||||
These tests will fail if new enum values are added without updating the
|
||||
corresponding helper sets, ensuring future maintainers don't forget to
|
||||
categorize new values.
|
||||
"""
|
||||
|
||||
def test_all_operations_are_categorized(self):
|
||||
"""Every Operation must be in _READ_ONLY_OPERATIONS, _MUTATING_OPERATIONS, or _INDETERMINATE_OPERATIONS."""
|
||||
all_operations = set(Operation)
|
||||
categorized_operations = _READ_ONLY_OPERATIONS | _MUTATING_OPERATIONS | _INDETERMINATE_OPERATIONS
|
||||
|
||||
# Check that every operation is categorized
|
||||
uncategorized = all_operations - categorized_operations
|
||||
assert not uncategorized, (
|
||||
f"The following Operation values are not categorized in _READ_ONLY_OPERATIONS, "
|
||||
f"_MUTATING_OPERATIONS, or _INDETERMINATE_OPERATIONS: {uncategorized}. "
|
||||
f"Please add them to the appropriate set in arcade_core/metadata.py"
|
||||
)
|
||||
|
||||
# Check that there are no extra operations in the sets that don't exist in the enum
|
||||
extra = categorized_operations - all_operations
|
||||
assert not extra, (
|
||||
f"The following values are in _READ_ONLY_OPERATIONS, _MUTATING_OPERATIONS, or "
|
||||
f"_INDETERMINATE_OPERATIONS but don't exist in the Operation enum: {extra}"
|
||||
)
|
||||
|
||||
def test_operation_categories_are_disjoint(self):
|
||||
"""_READ_ONLY_OPERATIONS, _MUTATING_OPERATIONS, and _INDETERMINATE_OPERATIONS should not overlap."""
|
||||
ro_mut = _READ_ONLY_OPERATIONS & _MUTATING_OPERATIONS
|
||||
assert not ro_mut, (
|
||||
f"The following Operation values appear in both _READ_ONLY_OPERATIONS and "
|
||||
f"_MUTATING_OPERATIONS: {ro_mut}. An operation should be in exactly one category."
|
||||
)
|
||||
|
||||
ro_ind = _READ_ONLY_OPERATIONS & _INDETERMINATE_OPERATIONS
|
||||
assert not ro_ind, (
|
||||
f"The following Operation values appear in both _READ_ONLY_OPERATIONS and "
|
||||
f"_INDETERMINATE_OPERATIONS: {ro_ind}. An operation should be in exactly one category."
|
||||
)
|
||||
|
||||
mut_ind = _MUTATING_OPERATIONS & _INDETERMINATE_OPERATIONS
|
||||
assert not mut_ind, (
|
||||
f"The following Operation values appear in both _MUTATING_OPERATIONS and "
|
||||
f"_INDETERMINATE_OPERATIONS: {mut_ind}. An operation should be in exactly one category."
|
||||
)
|
||||
|
||||
|
||||
class TestToolMetadataValidation:
|
||||
"""Test strict mode validation rules for ToolMetadata."""
|
||||
|
||||
def test_valid_metadata_passes(self):
|
||||
"""Valid metadata with consistent values should not raise."""
|
||||
metadata = ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.EMAIL],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
open_world=True,
|
||||
),
|
||||
)
|
||||
assert metadata is not None
|
||||
|
||||
def test_mutating_operation_with_read_only_raises(self):
|
||||
"""Mutating operations with read_only=True should raise when validated."""
|
||||
metadata = ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.CREATE], read_only=True),
|
||||
)
|
||||
with pytest.raises(
|
||||
ToolDefinitionError, match="mutating operation.*but is marked read_only=True"
|
||||
):
|
||||
metadata.validate_for_tool()
|
||||
|
||||
def test_opaque_with_read_only_raises(self):
|
||||
"""OPAQUE operation with read_only=True should raise when validated."""
|
||||
metadata = ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.OPAQUE], read_only=True),
|
||||
)
|
||||
with pytest.raises(
|
||||
ToolDefinitionError, match="OPAQUE operation but is marked read_only=True"
|
||||
):
|
||||
metadata.validate_for_tool()
|
||||
|
||||
def test_delete_without_destructive_raises(self):
|
||||
"""DELETE operation without destructive=True should raise when validated."""
|
||||
metadata = ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.DELETE], destructive=False),
|
||||
)
|
||||
with pytest.raises(
|
||||
ToolDefinitionError, match="'DELETE' operation.*but is not marked destructive=True"
|
||||
):
|
||||
metadata.validate_for_tool()
|
||||
|
||||
def test_service_domain_with_open_world_false_raises(self):
|
||||
"""ServiceDomain present with open_world=False should raise when validated."""
|
||||
metadata = ToolMetadata(
|
||||
classification=Classification(service_domains=[ServiceDomain.EMAIL]),
|
||||
behavior=Behavior(open_world=False),
|
||||
)
|
||||
with pytest.raises(
|
||||
ToolDefinitionError, match="ServiceDomain.*but is marked open_world=False"
|
||||
):
|
||||
metadata.validate_for_tool()
|
||||
|
||||
def test_strict_false_bypasses_validation(self):
|
||||
"""Setting strict=False should bypass all validation rules."""
|
||||
# This would normally raise due to contradiction
|
||||
metadata = ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.CREATE], read_only=True),
|
||||
strict=False,
|
||||
)
|
||||
# No error should be raised when validate_for_tool is called
|
||||
metadata.validate_for_tool() # Should not raise
|
||||
assert metadata is not None
|
||||
|
||||
def test_error_message_includes_operation_name(self):
|
||||
"""Error messages should include the operation name for debugging."""
|
||||
metadata = ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.CREATE], read_only=True),
|
||||
)
|
||||
with pytest.raises(ToolDefinitionError, match="Tool has the mutating operation"):
|
||||
metadata.validate_for_tool()
|
||||
|
||||
def test_read_only_operation_with_read_only_true_passes(self):
|
||||
"""READ operation with read_only=True should pass validation."""
|
||||
metadata = ToolMetadata(
|
||||
behavior=Behavior(operations=[Operation.READ], read_only=True),
|
||||
)
|
||||
assert metadata is not None
|
||||
assert metadata.behavior.read_only is True
|
||||
|
||||
def test_multiple_service_domains_allowed(self):
|
||||
"""Tools can have multiple service domains."""
|
||||
metadata = ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.CLOUD_STORAGE, ServiceDomain.DOCUMENTS],
|
||||
),
|
||||
behavior=Behavior(operations=[Operation.READ], read_only=True, open_world=True),
|
||||
)
|
||||
assert len(metadata.classification.service_domains) == 2
|
||||
|
||||
def test_extras_accepts_arbitrary_dict(self):
|
||||
"""Extras field accepts arbitrary key/value pairs."""
|
||||
metadata = ToolMetadata(
|
||||
extras={"idp": "entraID", "requires_mfa": True, "max_requests": 100},
|
||||
)
|
||||
assert metadata.extras["idp"] == "entraID"
|
||||
assert metadata.extras["requires_mfa"] is True
|
||||
assert metadata.extras["max_requests"] == 100
|
||||
|
||||
|
||||
class TestToolDecoratorWithMetadata:
|
||||
"""Test @tool decorator with metadata parameter."""
|
||||
|
||||
def test_decorator_accepts_metadata(self):
|
||||
"""Decorator should store metadata as __tool_metadata__ attribute."""
|
||||
|
||||
@tool(
|
||||
desc="Test tool",
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(service_domains=[ServiceDomain.MESSAGING]),
|
||||
behavior=Behavior(operations=[Operation.CREATE], open_world=True),
|
||||
),
|
||||
)
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
assert hasattr(my_tool, "__tool_metadata__")
|
||||
assert my_tool.__tool_metadata__.classification.service_domains == [ServiceDomain.MESSAGING]
|
||||
|
||||
def test_decorator_without_metadata_is_backward_compatible(self):
|
||||
"""Decorator should work without metadata (existing tools unchanged)."""
|
||||
|
||||
@tool(desc="Test tool")
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
assert getattr(my_tool, "__tool_metadata__", None) is None
|
||||
|
||||
|
||||
class TestToolDefinitionWithMetadata:
|
||||
"""Test ToolDefinition includes metadata from decorator."""
|
||||
|
||||
def test_tool_definition_includes_metadata(self):
|
||||
"""ToolDefinition.metadata should be populated from decorator."""
|
||||
|
||||
@tool(
|
||||
desc="Send a message",
|
||||
metadata=ToolMetadata(
|
||||
classification=Classification(
|
||||
service_domains=[ServiceDomain.MESSAGING],
|
||||
),
|
||||
behavior=Behavior(
|
||||
operations=[Operation.CREATE],
|
||||
read_only=False,
|
||||
destructive=False,
|
||||
open_world=True,
|
||||
),
|
||||
extras={"idp": "entraID"},
|
||||
),
|
||||
)
|
||||
def send_message() -> str:
|
||||
"""Send a message."""
|
||||
return "sent"
|
||||
|
||||
definition = ToolCatalog.create_tool_definition(
|
||||
send_message, toolkit_name="TestToolkit", toolkit_version="1.0.0"
|
||||
)
|
||||
|
||||
assert definition.metadata is not None
|
||||
assert definition.metadata.classification.service_domains == [ServiceDomain.MESSAGING]
|
||||
assert definition.metadata.behavior.operations == [Operation.CREATE]
|
||||
assert definition.metadata.extras == {"idp": "entraID"}
|
||||
|
||||
def test_tool_definition_without_metadata_is_none(self):
|
||||
"""ToolDefinition.metadata should be None when not provided."""
|
||||
|
||||
@tool(desc="Simple tool")
|
||||
def simple_tool() -> str:
|
||||
"""A simple tool."""
|
||||
return "done"
|
||||
|
||||
definition = ToolCatalog.create_tool_definition(
|
||||
simple_tool, toolkit_name="TestToolkit", toolkit_version="1.0.0"
|
||||
)
|
||||
|
||||
assert definition.metadata is None
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-mcp"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
description = "Arcade.dev - Tool Calling platform for Agents"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
@ -19,8 +19,8 @@ requires-python = ">=3.10"
|
|||
|
||||
dependencies = [
|
||||
# CLI dependencies
|
||||
"arcade-mcp-server>=1.15.1,<2.0.0",
|
||||
"arcade-core>=4.2.2,<5.0.0",
|
||||
"arcade-mcp-server>=1.17.0,<2.0.0",
|
||||
"arcade-core>=4.4.0,<5.0.0",
|
||||
"typer==0.10.0",
|
||||
"rich>=14.0.0,<15.0.0",
|
||||
"Jinja2==3.1.6",
|
||||
|
|
@ -42,12 +42,6 @@ all = [
|
|||
"numpy>=2.0.0",
|
||||
"scikit-learn>=1.5.0",
|
||||
"pytz>=2024.1",
|
||||
# mcp server
|
||||
"arcade-mcp-server>=1.14.0,<2.0.0",
|
||||
# serve
|
||||
"arcade-serve>=3.2.0,<4.0.0",
|
||||
# tdk
|
||||
"arcade-tdk>=3.4.0,<4.0.0",
|
||||
]
|
||||
|
||||
evals = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue