Spaces:
Running
Running
Jeremiah Lowin
Ensure resource + template names are properly prefixed when importing/mounting (#1423)
8d16d26 unverified | from pathlib import Path | |
| from tempfile import NamedTemporaryFile | |
| import pytest | |
| from pydantic import AnyUrl, FileUrl | |
| from fastmcp.exceptions import NotFoundError, ResourceError | |
| from fastmcp.resources import ( | |
| FileResource, | |
| ResourceManager, | |
| ResourceTemplate, | |
| ) | |
| from fastmcp.resources.resource import FunctionResource | |
| from fastmcp.utilities.tests import caplog_for_fastmcp | |
| def temp_file(): | |
| """Create a temporary file for testing. | |
| File is automatically cleaned up after the test if it still exists. | |
| """ | |
| content = "test content" | |
| with NamedTemporaryFile(mode="w", delete=False) as f: | |
| f.write(content) | |
| path = Path(f.name).resolve() | |
| yield path | |
| try: | |
| path.unlink() | |
| except FileNotFoundError: | |
| pass # File was already deleted by the test | |
| class TestResourceManager: | |
| """Test ResourceManager functionality.""" | |
| async def test_add_resource(self, temp_file: Path): | |
| """Test adding a resource.""" | |
| manager = ResourceManager() | |
| file_url = "file://test-resource" | |
| resource = FileResource( | |
| uri=FileUrl(file_url), | |
| name="test", | |
| path=temp_file, | |
| ) | |
| added = manager.add_resource(resource) | |
| assert added == resource | |
| # Get the actual key from the resource manager | |
| resources = await manager.get_resources() | |
| assert len(resources) == 1 | |
| assert resource in resources.values() | |
| async def test_add_duplicate_resource(self, temp_file: Path): | |
| """Test adding the same resource twice.""" | |
| manager = ResourceManager() | |
| file_url = "file://test-resource" | |
| resource = FileResource( | |
| uri=FileUrl(file_url), | |
| name="test", | |
| path=temp_file, | |
| ) | |
| first = manager.add_resource(resource) | |
| second = manager.add_resource(resource) | |
| assert first == second | |
| # Check the resource is there | |
| resources = await manager.get_resources() | |
| assert len(resources) == 1 | |
| assert resource in resources.values() | |
| async def test_warn_on_duplicate_resources(self, temp_file: Path, caplog): | |
| """Test warning on duplicate resources.""" | |
| manager = ResourceManager(duplicate_behavior="warn") | |
| file_url = "file://test-resource" | |
| resource = FileResource( | |
| uri=FileUrl(file_url), | |
| name="test_resource", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource) | |
| with caplog_for_fastmcp(caplog): | |
| manager.add_resource(resource) | |
| assert "Resource already exists" in caplog.text | |
| # Should have the resource | |
| resources = await manager.get_resources() | |
| assert len(resources) == 1 | |
| assert resource in resources.values() | |
| async def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog): | |
| """Test disabling warning on duplicate resources.""" | |
| manager = ResourceManager(duplicate_behavior="ignore") | |
| resource = FileResource( | |
| uri=FileUrl(f"file://{temp_file.name}"), | |
| name="test", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource) | |
| manager.add_resource(resource) | |
| assert "Resource already exists" not in caplog.text | |
| async def test_error_on_duplicate_resources(self, temp_file: Path): | |
| """Test error on duplicate resources.""" | |
| manager = ResourceManager(duplicate_behavior="error") | |
| resource = FileResource( | |
| uri=FileUrl(f"file://{temp_file.name}"), | |
| name="test_resource", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource) | |
| with pytest.raises(ValueError, match="Resource already exists"): | |
| manager.add_resource(resource) | |
| async def test_replace_duplicate_resources(self, temp_file: Path): | |
| """Test replacing duplicate resources.""" | |
| manager = ResourceManager(duplicate_behavior="replace") | |
| file_url = "file://test-resource" | |
| resource1 = FileResource( | |
| uri=FileUrl(file_url), | |
| name="original", | |
| path=temp_file, | |
| ) | |
| resource2 = FileResource( | |
| uri=FileUrl(file_url), | |
| name="replacement", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource1) | |
| manager.add_resource(resource2) | |
| # Should have replaced with the new resource | |
| resources = await manager.get_resources() | |
| resource_list = list(resources.values()) | |
| assert len(resource_list) == 1 | |
| assert resource_list[0].name == "replacement" | |
| async def test_ignore_duplicate_resources(self, temp_file: Path): | |
| """Test ignoring duplicate resources.""" | |
| manager = ResourceManager(duplicate_behavior="ignore") | |
| file_url = "file://test-resource" | |
| resource1 = FileResource( | |
| uri=FileUrl(file_url), | |
| name="original", | |
| path=temp_file, | |
| ) | |
| resource2 = FileResource( | |
| uri=FileUrl(file_url), | |
| name="replacement", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource1) | |
| result = manager.add_resource(resource2) | |
| # Should keep the original | |
| resources = await manager.get_resources() | |
| resource_list = list(resources.values()) | |
| assert len(resource_list) == 1 | |
| assert resource_list[0].name == "original" | |
| # Result should be the original resource | |
| assert result.name == "original" | |
| async def test_warn_on_duplicate_templates(self, caplog): | |
| """Test warning on duplicate templates.""" | |
| manager = ResourceManager(duplicate_behavior="warn") | |
| def template_fn(id: str) -> str: | |
| return f"Template {id}" | |
| template = ResourceTemplate.from_function( | |
| fn=template_fn, | |
| uri_template="test://{id}", | |
| name="test_template", | |
| ) | |
| manager.add_template(template) | |
| with caplog_for_fastmcp(caplog): | |
| manager.add_template(template) | |
| assert "Template already exists" in caplog.text | |
| # Should have the template | |
| templates = await manager.get_resource_templates() | |
| assert templates == {"test://{id}": template} | |
| async def test_error_on_duplicate_templates(self): | |
| """Test error on duplicate templates.""" | |
| manager = ResourceManager(duplicate_behavior="error") | |
| def template_fn(id: str) -> str: | |
| return f"Template {id}" | |
| template = ResourceTemplate.from_function( | |
| fn=template_fn, | |
| uri_template="test://{id}", | |
| name="test_template", | |
| ) | |
| manager.add_template(template) | |
| with pytest.raises(ValueError, match="Template already exists"): | |
| manager.add_template(template) | |
| async def test_replace_duplicate_templates(self): | |
| """Test replacing duplicate templates.""" | |
| manager = ResourceManager(duplicate_behavior="replace") | |
| def original_fn(id: str) -> str: | |
| return f"Original {id}" | |
| def replacement_fn(id: str) -> str: | |
| return f"Replacement {id}" | |
| template1 = ResourceTemplate.from_function( | |
| fn=original_fn, | |
| uri_template="test://{id}", | |
| name="original", | |
| ) | |
| template2 = ResourceTemplate.from_function( | |
| fn=replacement_fn, | |
| uri_template="test://{id}", | |
| name="replacement", | |
| ) | |
| manager.add_template(template1) | |
| manager.add_template(template2) | |
| # Should have replaced with the new template | |
| templates_dict = await manager.get_resource_templates() | |
| templates = list(templates_dict.values()) | |
| assert len(templates) == 1 | |
| assert templates[0].name == "replacement" | |
| async def test_ignore_duplicate_templates(self): | |
| """Test ignoring duplicate templates.""" | |
| manager = ResourceManager(duplicate_behavior="ignore") | |
| def original_fn(id: str) -> str: | |
| return f"Original {id}" | |
| def replacement_fn(id: str) -> str: | |
| return f"Replacement {id}" | |
| template1 = ResourceTemplate.from_function( | |
| fn=original_fn, | |
| uri_template="test://{id}", | |
| name="original", | |
| ) | |
| template2 = ResourceTemplate.from_function( | |
| fn=replacement_fn, | |
| uri_template="test://{id}", | |
| name="replacement", | |
| ) | |
| manager.add_template(template1) | |
| result = manager.add_template(template2) | |
| # Should keep the original | |
| templates_dict = await manager.get_resource_templates() | |
| templates = list(templates_dict.values()) | |
| assert len(templates) == 1 | |
| assert templates[0].name == "original" | |
| # Result should be the original template | |
| assert result.name == "original" | |
| async def test_get_resource(self, temp_file: Path): | |
| """Test getting a resource by URI.""" | |
| manager = ResourceManager() | |
| resource = FileResource( | |
| uri=FileUrl(f"file://{temp_file.name}"), | |
| name="test", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource) | |
| retrieved = await manager.get_resource(resource.uri) | |
| assert retrieved == resource | |
| async def test_get_resource_from_template(self): | |
| """Test getting a resource through a template.""" | |
| manager = ResourceManager() | |
| def greet(name: str) -> str: | |
| return f"Hello, {name}!" | |
| template = ResourceTemplate.from_function( | |
| fn=greet, | |
| uri_template="greet://{name}", | |
| name="greeter", | |
| ) | |
| manager._templates[template.uri_template] = template | |
| resource = await manager.get_resource(AnyUrl("greet://world")) | |
| assert isinstance(resource, FunctionResource) | |
| content = await resource.read() | |
| assert content == "Hello, world!" | |
| async def test_get_unknown_resource(self): | |
| """Test getting a non-existent resource.""" | |
| manager = ResourceManager() | |
| with pytest.raises(NotFoundError, match="Unknown resource"): | |
| await manager.get_resource(AnyUrl("unknown://test")) | |
| async def test_get_resources(self, temp_file: Path): | |
| """Test retrieving all resources.""" | |
| manager = ResourceManager() | |
| file_url1 = "file://test-resource1" | |
| resource1 = FileResource( | |
| uri=FileUrl(file_url1), | |
| name="test1", | |
| path=temp_file, | |
| ) | |
| file_url2 = "file://test-resource2" | |
| resource2 = FileResource( | |
| uri=FileUrl(file_url2), | |
| name="test2", | |
| path=temp_file, | |
| ) | |
| manager.add_resource(resource1) | |
| manager.add_resource(resource2) | |
| resources = await manager.get_resources() | |
| assert len(resources) == 2 | |
| values = list(resources.values()) | |
| assert resource1 in values | |
| assert resource2 in values | |
| class TestResourceTags: | |
| """Test functionality related to resource tags.""" | |
| async def test_add_resource_with_tags(self, temp_file: Path): | |
| """Test adding a resource with tags.""" | |
| manager = ResourceManager() | |
| resource = FileResource( | |
| uri=FileUrl("file://weather-data"), | |
| name="weather_data", | |
| path=temp_file, | |
| tags={"weather", "data"}, | |
| ) | |
| manager.add_resource(resource) | |
| # Check that tags are preserved | |
| resources_dict = await manager.get_resources() | |
| resources = list(resources_dict.values()) | |
| assert len(resources) == 1 | |
| assert resources[0].tags == {"weather", "data"} | |
| async def test_add_function_resource_with_tags(self): | |
| """Test adding a function resource with tags.""" | |
| manager = ResourceManager() | |
| async def get_data(): | |
| return "Sample data" | |
| resource = FunctionResource( | |
| uri=AnyUrl("data://sample"), | |
| name="sample_data", | |
| description="Sample data resource", | |
| mime_type="text/plain", | |
| fn=get_data, | |
| tags={"sample", "test", "data"}, | |
| ) | |
| manager.add_resource(resource) | |
| resources_dict = await manager.get_resources() | |
| resources = list(resources_dict.values()) | |
| assert len(resources) == 1 | |
| assert resources[0].tags == {"sample", "test", "data"} | |
| async def test_add_template_with_tags(self): | |
| """Test adding a resource template with tags.""" | |
| manager = ResourceManager() | |
| def user_data(user_id: str) -> str: | |
| return f"Data for user {user_id}" | |
| template = ResourceTemplate.from_function( | |
| fn=user_data, | |
| uri_template="users://{user_id}", | |
| name="user_template", | |
| description="Get user data by ID", | |
| tags={"users", "template", "data"}, | |
| ) | |
| manager.add_template(template) | |
| templates_dict = await manager.get_resource_templates() | |
| templates = list(templates_dict.values()) | |
| assert len(templates) == 1 | |
| assert templates[0].tags == {"users", "template", "data"} | |
| async def test_filter_resources_by_tags(self, temp_file: Path): | |
| """Test filtering resources by tags.""" | |
| manager = ResourceManager() | |
| # Create multiple resources with different tags | |
| resource1 = FileResource( | |
| uri=FileUrl("file://weather-data"), | |
| name="weather_data", | |
| path=temp_file, | |
| tags={"weather", "data"}, | |
| ) | |
| async def get_user_data(): | |
| return "User data" | |
| resource2 = FunctionResource( | |
| uri=AnyUrl("data://users"), | |
| name="user_data", | |
| description="User data resource", | |
| mime_type="text/plain", | |
| fn=get_user_data, | |
| tags={"users", "data"}, | |
| ) | |
| async def get_system_data(): | |
| return "System data" | |
| resource3 = FunctionResource( | |
| uri=AnyUrl("data://system"), | |
| name="system_data", | |
| description="System data resource", | |
| mime_type="text/plain", | |
| fn=get_system_data, | |
| tags={"system", "admin"}, | |
| ) | |
| manager.add_resource(resource1) | |
| manager.add_resource(resource2) | |
| manager.add_resource(resource3) | |
| # Filter by tags | |
| resources_dict = await manager.get_resources() | |
| data_resources = [r for r in resources_dict.values() if "data" in r.tags] | |
| assert len(data_resources) == 2 | |
| assert {r.name for r in data_resources} == {"weather_data", "user_data"} | |
| admin_resources = [r for r in resources_dict.values() if "admin" in r.tags] | |
| assert len(admin_resources) == 1 | |
| assert admin_resources[0].name == "system_data" | |
| class TestCustomResourceKeys: | |
| """Test adding resources and templates with custom keys.""" | |
| def test_add_resource_with_custom_key(self, temp_file: Path): | |
| """Test adding a resource with a custom key different from its URI.""" | |
| manager = ResourceManager() | |
| original_uri = "data://test/resource" | |
| custom_key = "custom://resource/key" | |
| # Create a function resource instead of file resource to avoid path issues | |
| async def get_data(): | |
| return "Test data" | |
| resource = FunctionResource( | |
| uri=AnyUrl(original_uri), | |
| name="test_resource", | |
| fn=get_data, | |
| ) | |
| # Use model_copy to create a new resource with the custom key | |
| resource_with_custom_key = resource.model_copy(key=custom_key) | |
| manager.add_resource(resource_with_custom_key) | |
| # Resource should be accessible via custom key | |
| assert custom_key in manager._resources | |
| # But not via its original URI | |
| assert original_uri not in manager._resources | |
| # The resource's internal URI remains unchanged | |
| assert str(manager._resources[custom_key].uri) == original_uri | |
| def test_add_template_with_custom_key(self): | |
| """Test adding a template with a custom key different from its URI template.""" | |
| manager = ResourceManager() | |
| def template_fn(id: str) -> str: | |
| return f"Template {id}" | |
| original_uri_template = "test://{id}" | |
| custom_key = "custom://{id}/template" | |
| template = ResourceTemplate.from_function( | |
| fn=template_fn, | |
| uri_template=original_uri_template, | |
| name="test_template", | |
| ) | |
| # Use model_copy to create a new template with the custom key | |
| template_with_custom_key = template.model_copy(key=custom_key) | |
| manager.add_template(template_with_custom_key) | |
| # Template should be accessible via custom key | |
| assert custom_key in manager._templates | |
| # But not via its original URI template | |
| assert original_uri_template not in manager._templates | |
| # The template's internal URI template remains unchanged | |
| assert str(manager._templates[custom_key].uri_template) == original_uri_template | |
| async def test_get_resource_with_custom_key(self, temp_file: Path): | |
| """Test that get_resource works with resources added with custom keys.""" | |
| manager = ResourceManager() | |
| original_uri = "data://test/resource" | |
| custom_key = "custom://resource/path" | |
| # Create a function resource instead of file resource to avoid path issues | |
| async def get_data(): | |
| return "Test data" | |
| resource = FunctionResource( | |
| uri=AnyUrl(original_uri), | |
| name="test_resource", | |
| fn=get_data, | |
| ) | |
| # Use model_copy to create a new resource with the custom key | |
| resource_with_custom_key = resource.model_copy(key=custom_key) | |
| manager.add_resource(resource_with_custom_key) | |
| # Should be retrievable by the custom key | |
| retrieved = await manager.get_resource(custom_key) | |
| assert retrieved is not None | |
| assert str(retrieved.uri) == original_uri | |
| # Should NOT be retrievable by the original URI | |
| with pytest.raises(NotFoundError, match="Unknown resource"): | |
| await manager.get_resource(original_uri) | |
| async def test_get_resource_from_template_with_custom_key(self): | |
| """Test that templates with custom keys can create resources.""" | |
| manager = ResourceManager() | |
| def greet(name: str) -> str: | |
| return f"Hello, {name}!" | |
| original_template = "greet://{name}" | |
| custom_key = "custom://greet/{name}" | |
| template = ResourceTemplate.from_function( | |
| fn=greet, | |
| uri_template=original_template, | |
| name="custom_greeter", | |
| ) | |
| # Use model_copy to create a new template with the custom key | |
| template_with_custom_key = template.model_copy(key=custom_key) | |
| manager.add_template(template_with_custom_key) | |
| # Using a URI that matches the custom key pattern | |
| resource = await manager.get_resource("custom://greet/world") | |
| assert isinstance(resource, FunctionResource) | |
| content = await resource.read() | |
| assert content == "Hello, world!" | |
| # Shouldn't work with the original template pattern | |
| with pytest.raises(NotFoundError, match="Unknown resource"): | |
| await manager.get_resource("greet://world") | |
| class TestResourceErrorHandling: | |
| """Test error handling in the ResourceManager.""" | |
| async def test_resource_error_passthrough(self): | |
| """Test that ResourceErrors are passed through directly.""" | |
| manager = ResourceManager() | |
| async def error_resource(): | |
| """Resource that raises a ResourceError.""" | |
| raise ResourceError("Specific resource error") | |
| resource = FunctionResource( | |
| uri=AnyUrl("error://resource"), | |
| name="error_resource", | |
| fn=error_resource, | |
| ) | |
| manager.add_resource(resource) | |
| with pytest.raises(ResourceError, match="Specific resource error"): | |
| await manager.read_resource("error://resource") | |
| async def test_template_resource_error_passthrough(self): | |
| """Test that ResourceErrors from template-generated resources are passed through.""" | |
| manager = ResourceManager() | |
| def error_template(param: str): | |
| """Template that raises a ResourceError.""" | |
| raise ResourceError(f"Template error with param {param}") | |
| template = ResourceTemplate.from_function( | |
| fn=error_template, | |
| uri_template="error://{param}", | |
| name="error_template", | |
| ) | |
| manager.add_template(template) | |
| with pytest.raises(ResourceError) as excinfo: | |
| await manager.read_resource("error://test") | |
| # The original error message should be included in the ValueError | |
| assert "Template error with param test" in str(excinfo.value) | |
| async def test_exception_converted_to_resource_error_with_details(self): | |
| """Test that other exceptions are converted to ResourceError with details by default.""" | |
| manager = ResourceManager() | |
| async def buggy_resource(): | |
| """Resource that raises a ValueError.""" | |
| raise ValueError("Internal error details") | |
| resource = FunctionResource( | |
| uri=AnyUrl("buggy://resource"), | |
| name="buggy_resource", | |
| fn=buggy_resource, | |
| ) | |
| manager.add_resource(resource) | |
| with pytest.raises(ResourceError) as excinfo: | |
| await manager.read_resource("buggy://resource") | |
| # The error message should include the original exception details | |
| assert "Error reading resource 'buggy://resource'" in str(excinfo.value) | |
| assert "Internal error details" in str(excinfo.value) | |
| async def test_exception_converted_to_masked_resource_error(self): | |
| """Test that other exceptions are masked when enabled.""" | |
| manager = ResourceManager(mask_error_details=True) | |
| async def buggy_resource(): | |
| """Resource that raises a ValueError.""" | |
| raise ValueError("Internal error details") | |
| resource = FunctionResource( | |
| uri=AnyUrl("buggy://resource"), | |
| name="buggy_resource", | |
| fn=buggy_resource, | |
| ) | |
| manager.add_resource(resource) | |
| with pytest.raises(ResourceError) as excinfo: | |
| await manager.read_resource("buggy://resource") | |
| # The error message should not include the original exception details | |
| assert "Error reading resource 'buggy://resource'" in str(excinfo.value) | |
| assert "Internal error details" not in str(excinfo.value) | |