Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,6 @@ Config example: `docs/mcp.conf.example`

5. **Service plugin implicit contract** — plugins must have a `Service` class with a `name` attribute, but this is undocumented at the call site. Failed plugin loads are logged at DEBUG and silently skipped, which can cause confusing "method not found" errors at runtime.

6. **`templates.py:get_templates`** returns `list` instead of a `RootModel` — only tool that doesn't return a Pydantic model. Not enforced by the type system.

### Areas in Flux

- FastMCP API compatibility: v0.12.1 fixed a breaking change where `include_tags`/`exclude_tags` kwargs were removed in FastMCP 3.x in favor of `enable()`/`disable()` API. Watch for further FastMCP API changes.
Expand Down
10 changes: 5 additions & 5 deletions src/itential_mcp/tools/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async def get_templates(
Literal["textfsm", "jinja2"] | None,
Field(description="Retrieve only templates of this type", default=None),
],
) -> list[models.GetTemplatesElement]:
) -> models.GetTemplatesResponse:
"""Get all templates from Automation Studio.

Retrieves all templates from the Automation Studio, with optional filtering
Expand All @@ -45,9 +45,9 @@ async def get_templates(
templating. Defaults to None to retrieve all template types.

Returns:
list[models.GetTemplatesElement]: A list of template objects containing template
metadata including id, name, description, and type fields transformed
into GetTemplatesElement model objects.
models.GetTemplatesResponse: A RootModel wrapping a list of template
objects containing template metadata including name, description,
and type fields transformed into GetTemplatesElement model objects.

Raises:
Exception: If there is an error retrieving templates from the
Expand All @@ -68,7 +68,7 @@ async def get_templates(
)
)

return results
return models.GetTemplatesResponse(root=results)


@annotate(
Expand Down
148 changes: 104 additions & 44 deletions tests/test_tools_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
import pytest
from unittest.mock import AsyncMock, MagicMock

from fastmcp.tools import Tool

from itential_mcp.tools import templates as templates_module
from itential_mcp.tools.templates import (
get_templates,
describe_template,
create_template,
update_template,
)
from itential_mcp.models.templates import GetTemplatesElement, DescribeTemplateResponse
from itential_mcp.models.templates import (
GetTemplatesElement,
GetTemplatesResponse,
DescribeTemplateResponse,
)
from itential_mcp.utilities.tool import get_json_schema


class TestAutomationStudioTemplates:
Expand Down Expand Up @@ -84,25 +92,25 @@ async def test_get_templates_success_no_filter(
mock_context.debug.assert_called_once_with("inside get_templates(...)")

# Verify response structure and data
assert isinstance(result, list)
assert len(result) == 3
assert isinstance(result, GetTemplatesResponse)
assert len(result.root) == 3

# Check first template (TextFSM)
assert isinstance(result[0], GetTemplatesElement)
assert result[0].name == "Cisco Show Version Parser"
assert result[0].description == "Parse Cisco show version output"
assert result[0].type == "textfsm"
assert isinstance(result.root[0], GetTemplatesElement)
assert result.root[0].name == "Cisco Show Version Parser"
assert result.root[0].description == "Parse Cisco show version output"
assert result.root[0].type == "textfsm"

# Check second template (Jinja2)
assert isinstance(result[1], GetTemplatesElement)
assert result[1].name == "Interface Configuration Generator"
assert result[1].description == "Generate interface configuration"
assert result[1].type == "jinja2"
assert isinstance(result.root[1], GetTemplatesElement)
assert result.root[1].name == "Interface Configuration Generator"
assert result.root[1].description == "Generate interface configuration"
assert result.root[1].type == "jinja2"

# Check third template (TextFSM)
assert isinstance(result[2], GetTemplatesElement)
assert result[2].name == "BGP Neighbor Parser"
assert result[2].type == "textfsm"
assert isinstance(result.root[2], GetTemplatesElement)
assert result.root[2].name == "BGP Neighbor Parser"
assert result.root[2].type == "textfsm"

@pytest.mark.asyncio
async def test_get_templates_success_textfsm_filter(
Expand Down Expand Up @@ -136,10 +144,11 @@ async def test_get_templates_success_textfsm_filter(
)

# Verify response
assert len(result) == 2
assert all(template.type == "textfsm" for template in result)
assert result[0].name == "Cisco Parser"
assert result[1].name == "Juniper Parser"
assert isinstance(result, GetTemplatesResponse)
assert len(result.root) == 2
assert all(template.type == "textfsm" for template in result.root)
assert result.root[0].name == "Cisco Parser"
assert result.root[1].name == "Juniper Parser"

@pytest.mark.asyncio
async def test_get_templates_success_jinja2_filter(self, mock_context, mock_client):
Expand All @@ -165,9 +174,10 @@ async def test_get_templates_success_jinja2_filter(self, mock_context, mock_clie
)

# Verify response
assert len(result) == 1
assert result[0].type == "jinja2"
assert result[0].name == "Config Generator"
assert isinstance(result, GetTemplatesResponse)
assert len(result.root) == 1
assert result.root[0].type == "jinja2"
assert result.root[0].name == "Config Generator"

@pytest.mark.asyncio
async def test_get_templates_empty_response(self, mock_context, mock_client):
Expand All @@ -183,8 +193,9 @@ async def test_get_templates_empty_response(self, mock_context, mock_client):
)

# Verify empty response handling
assert isinstance(result, list)
assert len(result) == 0
assert isinstance(result, GetTemplatesResponse)
assert result.root == []
assert len(result.root) == 0

@pytest.mark.asyncio
async def test_get_templates_missing_optional_fields(
Expand Down Expand Up @@ -215,17 +226,17 @@ async def test_get_templates_missing_optional_fields(
result = await get_templates(mock_context, template_type=None)

# Verify handling of missing fields
assert len(result) == 2
assert len(result.root) == 2

# First template with missing description
assert result[0].name == "Minimal Template"
assert result[0].description is None
assert result[0].type == "textfsm"
assert result.root[0].name == "Minimal Template"
assert result.root[0].description is None
assert result.root[0].type == "textfsm"

# Second template with null description
assert result[1].name == "Null Description Template"
assert result[1].description is None
assert result[1].type == "jinja2"
assert result.root[1].name == "Null Description Template"
assert result.root[1].description is None
assert result.root[1].type == "jinja2"

@pytest.mark.asyncio
async def test_get_templates_service_error_propagation(
Expand Down Expand Up @@ -269,8 +280,8 @@ async def test_get_templates_model_validation_success(
result = await get_templates(mock_context, template_type="textfsm")

# Verify model validation passed
assert len(result) == 1
template = result[0]
assert len(result.root) == 1
template = result.root[0]
assert isinstance(template, GetTemplatesElement)
assert template.name == "Valid Template"
assert template.description == "This is a valid template"
Expand Down Expand Up @@ -317,15 +328,17 @@ async def test_get_templates_large_response_handling(
result = await get_templates(mock_context, template_type=None)

# Verify large response is handled correctly
assert len(result) == 250
assert all(isinstance(template, GetTemplatesElement) for template in result)
assert len(result.root) == 250
assert all(
isinstance(template, GetTemplatesElement) for template in result.root
)

# Spot check a few templates
assert result[0].name == "Template 0"
assert result[0].type == "textfsm"
assert result.root[0].name == "Template 0"
assert result.root[0].type == "textfsm"

assert result[249].name == "Template 249"
assert result[249].type == "jinja2"
assert result.root[249].name == "Template 249"
assert result.root[249].type == "jinja2"

@pytest.mark.asyncio
async def test_get_templates_data_transformation(self, mock_context, mock_client):
Expand All @@ -350,8 +363,8 @@ async def test_get_templates_data_transformation(self, mock_context, mock_client
result = await get_templates(mock_context, template_type=None)

# Verify transformation extracts only the model fields
assert len(result) == 1
template = result[0]
assert len(result.root) == 1
template = result.root[0]

# Check that only model fields are present
assert template.name == "Transform Test Template"
Expand Down Expand Up @@ -380,21 +393,68 @@ async def test_get_templates_mixed_types_response(self, mock_context, mock_clien
result = await get_templates(mock_context, template_type=None)

# Verify mixed types are handled correctly
assert len(result) == 4
assert len(result.root) == 4

# Check types are preserved
types = [template.type for template in result]
types = [template.type for template in result.root]
assert types.count("textfsm") == 2
assert types.count("jinja2") == 2

# Verify specific templates
textfsm_templates = [t for t in result if t.type == "textfsm"]
jinja2_templates = [t for t in result if t.type == "jinja2"]
textfsm_templates = [t for t in result.root if t.type == "textfsm"]
jinja2_templates = [t for t in result.root if t.type == "jinja2"]

assert len(textfsm_templates) == 2
assert len(jinja2_templates) == 2


class TestGetTemplatesOutputSchema:
"""Test cases pinning get_templates' output_schema/registration shape.

These guard the fix that routes get_templates through the
GetTemplatesResponse RootModel: the tool is not wire-broken today (see
the RootModel docstring), but before this fix its bare `list[...]`
return annotation caused `get_json_schema` to raise `ValueError` on
every server startup. These tests lock in that the warning path is
gone and that the final FastMCP-registered schema stays spec-compliant
(object-rooted) either way.
"""

def test_get_json_schema_no_longer_raises(self):
"""get_json_schema(get_templates) must succeed now that the return
annotation is a RootModel subclass (GetTemplatesResponse), instead
of raising ValueError for a bare list[...] annotation."""
schema = get_json_schema(templates_module.get_templates)

# RootModel wrapping a list produces a top-level array schema.
assert schema["type"] == "array"

def test_fastmcp_registered_output_schema_is_object_rooted(self):
"""The final FastMCP-registered output_schema must be object-rooted
(MCP spec requires an object at the schema root), regardless of
get_templates' underlying RootModel array schema."""
tool = Tool.from_function(templates_module.get_templates)

assert tool.output_schema["type"] == "object"
assert "result" in tool.output_schema["properties"]

def test_fastmcp_wraps_result_as_array(self):
"""The FastMCP-wrapped `result` property must resolve to an array
schema, documenting that the wire payload is still a flat array
under `result` (same shape produced before this fix)."""
tool = Tool.from_function(templates_module.get_templates)

result_schema = tool.output_schema["properties"]["result"]

# The result property may be a direct schema or a $ref into $defs
# depending on pydantic's schema generation; resolve either form.
if "$ref" in result_schema:
ref_name = result_schema["$ref"].rsplit("/", 1)[-1]
result_schema = tool.output_schema["$defs"][ref_name]

assert result_schema["type"] == "array"


class TestDescribeTemplate:
"""Test cases for the describe_template tool function"""

Expand Down
17 changes: 17 additions & 0 deletions tests/utilities/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ def test_get_json_schema_operations_manager_start_workflow(self):

assert "anyOf" in schema

def test_get_json_schema_templates_get_templates(self):
"""Regression test: get_templates now returns a RootModel
(GetTemplatesResponse) so get_json_schema must no longer raise
ValueError. Previously this tool's bare `list[...]` return
annotation tripped the "missing or invalid output_schema" warning
at server startup."""
from itential_mcp.tools import templates

schema = get_json_schema(templates.get_templates)

# RootModel wrapping a list produces a top-level array schema, not
# an object schema. server.py's own "== object" guard is what
# decides whether to adopt this as a custom output_schema (it does
# not, for an array-rooted schema) -- this test only pins that
# get_json_schema itself succeeds without raising.
assert schema["type"] == "array"


class TestTagsDecorator:
"""Test the tags decorator functionality"""
Expand Down
Loading