Files
homelable/mcp/tests/test_resources.py
T
Pouzor c85bb46373 fix(mcp): make resources/read and resources/templates/list work
Two bugs made the Resources tab unusable in any MCP client.

read_resource returned mcp.types.TextContent, but the low-level
Server.read_resource decorator expects str, bytes or an iterable of
ReadResourceContents and reads .content / .mime_type off each item.
TextContent carries .text, so every resources/read failed at
serialisation with "'TextContent' object has no attribute 'content'".
The handler itself runs, so the error only ever reached the client and
never showed up in the server log.

No list_resource_templates handler was registered either, so the SDK
answered resources/templates/list with "Method not found". That hid the
homelable://nodes/{node_id} template read_resource already serves — it
is absent from RESOURCE_LIST because it is a template, not a concrete
URI, so nothing advertised it at all.

ha-relevant: no
2026-08-31 11:46:53 +02:00

114 lines
3.8 KiB
Python

import json
import pytest
from pydantic import AnyUrl
from unittest.mock import AsyncMock, patch
from app.resources import read_resource
@pytest.fixture
def mock_backend():
with patch("app.resources.backend") as m:
m.get = AsyncMock(return_value={"data": "ok"})
yield m
@pytest.mark.anyio
async def test_read_canvas(mock_backend):
result = await read_resource("homelable://canvas")
mock_backend.get.assert_called_once_with("/api/v1/canvas")
assert len(result) == 1
@pytest.mark.anyio
async def test_read_nodes(mock_backend):
await read_resource("homelable://nodes")
mock_backend.get.assert_called_once_with("/api/v1/nodes")
@pytest.mark.anyio
async def test_read_edges(mock_backend):
await read_resource("homelable://edges")
mock_backend.get.assert_called_once_with("/api/v1/edges")
@pytest.mark.anyio
async def test_read_single_node(mock_backend):
await read_resource("homelable://nodes/abc123")
mock_backend.get.assert_called_once_with("/api/v1/nodes/abc123")
@pytest.mark.anyio
async def test_read_scan_pending(mock_backend):
await read_resource("homelable://scan/pending")
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
@pytest.mark.anyio
async def test_read_unknown_uri(mock_backend):
with pytest.raises(ValueError, match="Unknown resource URI"):
await read_resource("homelable://unknown")
@pytest.mark.anyio
async def test_read_resource_accepts_anyurl(mock_backend):
# Regression for #225: the MCP framework calls the handler with a pydantic
# AnyUrl, not a str, which raised "'AnyUrl' object has no attribute
# 'startswith'". The handler must coerce to str and still route correctly.
await read_resource(AnyUrl("homelable://canvas"))
mock_backend.get.assert_called_once_with("/api/v1/canvas")
@pytest.mark.anyio
async def test_read_edges_anyurl(mock_backend):
await read_resource(AnyUrl("homelable://edges"))
mock_backend.get.assert_called_once_with("/api/v1/edges")
@pytest.mark.anyio
async def test_read_single_node_anyurl(mock_backend):
await read_resource(AnyUrl("homelable://nodes/abc123"))
mock_backend.get.assert_called_once_with("/api/v1/nodes/abc123")
@pytest.mark.anyio
async def test_read_returns_read_resource_contents(mock_backend):
"""Regression: returning mcp.types.TextContent made every resources/read
fail client-side with "'TextContent' object has no attribute 'content'".
The low-level Server reads .content / .mime_type off each item."""
from mcp.server.lowlevel.helper_types import ReadResourceContents
result = await read_resource("homelable://canvas")
assert isinstance(result[0], ReadResourceContents)
assert result[0].mime_type == "application/json"
assert json.loads(result[0].content) == {"data": "ok"}
@pytest.mark.anyio
async def test_read_node_returns_read_resource_contents(mock_backend):
from mcp.server.lowlevel.helper_types import ReadResourceContents
result = await read_resource("homelable://nodes/abc123")
assert isinstance(result[0], ReadResourceContents)
assert result[0].mime_type == "application/json"
@pytest.mark.anyio
async def test_resource_templates_are_registered():
"""Regression: no list_resource_templates handler meant
resources/templates/list answered "Method not found", hiding the
homelable://nodes/{node_id} template read_resource already serves."""
from mcp.server import Server
from mcp.types import ListResourceTemplatesRequest
from app.resources import register_resources
server = Server("test")
register_resources(server)
handler = server.request_handlers[ListResourceTemplatesRequest]
result = await handler(None)
templates = result.root.resourceTemplates
assert [t.uriTemplate for t in templates] == ["homelable://nodes/{node_id}"]