import threading import anyio import anyio.from_thread import pytest from inline_snapshot import snapshot from mcp_types import InputRequiredResult from pydantic import BaseModel from mcp.server.mcpserver.resources import FunctionResource class TestFunctionResource: """Test FunctionResource functionality.""" def test_function_resource_creation(self): """Test creating a FunctionResource.""" def my_func() -> str: # pragma: no cover return "test content" resource = FunctionResource( uri="fn://test", name="test", description="test function", fn=my_func, ) assert str(resource.uri) == "fn://test" assert resource.name == "test" assert resource.description == "test function" assert resource.mime_type == "text/plain" # default assert resource.fn == my_func @pytest.mark.anyio async def test_read_text(self): """Test reading text from a FunctionResource.""" def get_data() -> str: return "Hello, world!" resource = FunctionResource( uri="function://test", name="test", fn=get_data, ) content = await resource.read() assert content == "Hello, world!" assert resource.mime_type == "text/plain" @pytest.mark.anyio async def test_read_binary(self): """Test reading binary data from a FunctionResource.""" def get_data() -> bytes: return b"Hello, world!" resource = FunctionResource( uri="function://test", name="test", fn=get_data, ) content = await resource.read() assert content == b"Hello, world!" @pytest.mark.anyio async def test_json_conversion(self): """Test automatic JSON conversion of non-string results.""" def get_data() -> dict[str, str]: return {"key": "value"} resource = FunctionResource( uri="function://test", name="test", fn=get_data, ) content = await resource.read() assert isinstance(content, str) assert '"key": "value"' in content @pytest.mark.anyio async def test_error_handling(self): """Test error handling in FunctionResource.""" def failing_func() -> str: raise ValueError("Test error") resource = FunctionResource( uri="function://test", name="test", fn=failing_func, ) with pytest.raises(ValueError, match="Error reading resource function://test"): await resource.read() @pytest.mark.anyio async def test_basemodel_conversion(self): """Test handling of BaseModel types.""" class MyModel(BaseModel): name: str resource = FunctionResource( uri="function://test", name="test", fn=lambda: MyModel(name="test"), ) content = await resource.read() assert content == '{\n "name": "test"\n}' @pytest.mark.anyio async def test_custom_type_conversion(self): """Test handling of custom types.""" class CustomData: def __str__(self) -> str: return "custom data" def get_data() -> CustomData: return CustomData() resource = FunctionResource( uri="function://test", name="test", fn=get_data, ) content = await resource.read() assert isinstance(content, str) @pytest.mark.anyio async def test_async_read_text(self): """Test reading text from async FunctionResource.""" async def get_data() -> str: return "Hello, world!" resource = FunctionResource( uri="function://test", name="test", fn=get_data, ) content = await resource.read() assert content == "Hello, world!" assert resource.mime_type == "text/plain" @pytest.mark.anyio async def test_from_function(self): """Test creating a FunctionResource from a function.""" async def get_data() -> str: # pragma: no cover """get_data returns a string""" return "Hello, world!" resource = FunctionResource.from_function( fn=get_data, uri="function://test", name="test", ) assert resource.description == "get_data returns a string" assert resource.mime_type == "text/plain" assert resource.name == "test" assert resource.uri == "function://test" class TestFunctionResourceMetadata: def test_from_function_with_metadata(self): # from_function() accepts meta dict and stores it on the resource for static resources def get_data() -> str: # pragma: no cover return "test data" metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]} resource = FunctionResource.from_function( fn=get_data, uri="resource://data", meta=metadata, ) assert resource.meta is not None assert resource.meta == metadata assert resource.meta["cache_ttl"] == 300 assert "data" in resource.meta["tags"] assert "readonly" in resource.meta["tags"] def test_from_function_without_metadata(self): # meta parameter is optional and defaults to None for backward compatibility def get_data() -> str: # pragma: no cover return "test data" resource = FunctionResource.from_function( fn=get_data, uri="resource://data", ) assert resource.meta is None @pytest.mark.anyio async def test_sync_fn_runs_in_worker_thread(): """Sync resource functions must run in a worker thread, not the event loop.""" main_thread = threading.get_ident() fn_thread: list[int] = [] def blocking_fn() -> str: fn_thread.append(threading.get_ident()) return "data" resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn) result = await resource.read() assert result == "data" assert fn_thread[0] != main_thread @pytest.mark.anyio async def test_sync_fn_does_not_block_event_loop(): """A blocking sync resource function must not stall the event loop. On regression (sync runs inline), anyio.from_thread.run_sync raises RuntimeError because there is no worker-thread context, failing fast. """ handler_entered = anyio.Event() release = threading.Event() def blocking_fn() -> str: anyio.from_thread.run_sync(handler_entered.set) release.wait() return "done" resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn) result: list[str | bytes] = [] async def run() -> None: result.append(await resource.read()) with anyio.fail_after(5): async with anyio.create_task_group() as tg: tg.start_soon(run) await handler_entered.wait() release.set() assert result == ["done"] @pytest.mark.anyio async def test_read_rejects_an_input_required_result_from_a_static_function(): """A static resource function returning an InputRequiredResult is a mistake (it can never read the retry's input_responses), so read() raises instead of JSON-dumping it.""" def ask() -> InputRequiredResult: return InputRequiredResult(request_state="round-1") resource = FunctionResource(uri="resource://ask", name="ask", fn=ask) with pytest.raises(ValueError) as exc: await resource.read() assert str(exc.value) == snapshot( "Error reading resource resource://ask: static resources cannot return " "InputRequiredResult; only resource template functions participate in the multi-round-trip flow" )