arcade-mcp/libs/tests/arcade_mcp_server/test_tool_metadata_serialization.py
2026-02-17 14:31:45 -08:00

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