372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""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"}
|