From 3a0faf5fcf89ad0a54a1ddcbf59a4e555f939cea Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:06:59 -0400 Subject: [PATCH] Formalize resource/functionresource replationship --- src/fastmcp/resources/__init__.py | 3 +- src/fastmcp/resources/resource.py | 88 +++++++++++++++++++++- src/fastmcp/resources/resource_manager.py | 9 +-- src/fastmcp/resources/template.py | 9 +-- src/fastmcp/resources/types.py | 44 ----------- tests/resources/test_function_resources.py | 2 +- tests/resources/test_resource_manager.py | 2 +- tests/resources/test_resource_template.py | 3 +- tests/resources/test_resources.py | 3 +- tests/server/test_proxy.py | 13 +++- tests/server/test_server_interactions.py | 3 +- 11 files changed, 114 insertions(+), 65 deletions(-) diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py index 2acd68c71..3b36a4a62 100644 --- a/src/fastmcp/resources/__init__.py +++ b/src/fastmcp/resources/__init__.py @@ -1,10 +1,9 @@ -from .resource import Resource +from .resource import FunctionResource, Resource from .template import ResourceTemplate from .types import ( BinaryResource, DirectoryResource, FileResource, - FunctionResource, HttpResource, TextResource, ) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 6121c0a51..a4fc2dfcc 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -3,8 +3,11 @@ from __future__ import annotations import abc +import inspect +from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any +import pydantic_core from mcp.types import Resource as MCPResource from pydantic import ( AnyUrl, @@ -16,7 +19,12 @@ from pydantic import ( field_validator, ) -from fastmcp.utilities.types import FastMCPBaseModel, _convert_set_defaults +from fastmcp.server.dependencies import get_context +from fastmcp.utilities.types import ( + FastMCPBaseModel, + _convert_set_defaults, + find_kwarg_by_type, +) if TYPE_CHECKING: pass @@ -43,6 +51,24 @@ class Resource(FastMCPBaseModel, abc.ABC): pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", ) + @staticmethod + def from_function( + fn: Callable[[], Any], + uri: str | AnyUrl, + name: str | None = None, + description: str | None = None, + mime_type: str | None = None, + tags: set[str] | None = None, + ) -> FunctionResource: + return FunctionResource.from_function( + fn=fn, + uri=uri, + name=name, + description=description, + mime_type=mime_type, + tags=tags, + ) + @field_validator("mime_type", mode="before") @classmethod def set_default_mime_type(cls, mime_type: str | None) -> str: @@ -80,3 +106,63 @@ class Resource(FastMCPBaseModel, abc.ABC): "mimeType": self.mime_type, } return MCPResource(**kwargs | overrides) + + +class FunctionResource(Resource): + """A resource that defers data loading by wrapping a function. + + The function is only called when the resource is read, allowing for lazy loading + of potentially expensive data. This is particularly useful when listing resources, + as the function won't be called until the resource is actually accessed. + + The function can return: + - str for text content (default) + - bytes for binary content + - other types will be converted to JSON + """ + + fn: Callable[[], Any] + + @classmethod + def from_function( + cls, + fn: Callable[[], Any], + uri: str | AnyUrl, + name: str | None = None, + description: str | None = None, + mime_type: str | None = None, + tags: set[str] | None = None, + ) -> FunctionResource: + """Create a FunctionResource from a function.""" + if isinstance(uri, str): + uri = AnyUrl(uri) + return cls( + fn=fn, + uri=uri, + name=name or fn.__name__, + description=description or fn.__doc__, + mime_type=mime_type or "text/plain", + tags=tags or set(), + ) + + async def read(self) -> str | bytes: + """Read the resource by calling the wrapped function.""" + from fastmcp.server.context import Context + + kwargs = {} + context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context) + if context_kwarg is not None: + kwargs[context_kwarg] = get_context() + + result = self.fn(**kwargs) + if inspect.iscoroutinefunction(self.fn): + result = await result + + if isinstance(result, Resource): + return await result.read() + elif isinstance(result, bytes): + return result + elif isinstance(result, str): + return result + else: + return pydantic_core.to_json(result, fallback=str, indent=2).decode() diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index c3b74e5a4..de4945c6a 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -7,7 +7,6 @@ from typing import Any from pydantic import AnyUrl from fastmcp.exceptions import NotFoundError, ResourceError -from fastmcp.resources import FunctionResource from fastmcp.resources.resource import Resource from fastmcp.resources.template import ( ResourceTemplate, @@ -121,13 +120,13 @@ class ResourceManager: The added resource. If a resource with the same URI already exists, returns the existing resource. """ - resource = FunctionResource( + resource = Resource.from_function( fn=fn, - uri=AnyUrl(uri), + uri=uri, name=name, description=description, - mime_type=mime_type or "text/plain", - tags=tags or set(), + mime_type=mime_type, + tags=tags, ) return self.add_resource(resource) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index dbbdb5338..5579e3f28 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -10,14 +10,13 @@ from urllib.parse import unquote from mcp.types import ResourceTemplate as MCPResourceTemplate from pydantic import ( - AnyUrl, BeforeValidator, Field, field_validator, validate_call, ) -from fastmcp.resources.types import FunctionResource, Resource +from fastmcp.resources.types import Resource from fastmcp.server.dependencies import get_context from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import ( @@ -189,12 +188,12 @@ class ResourceTemplate(FastMCPBaseModel): result = await result return result - return FunctionResource( - uri=AnyUrl(uri), # Explicitly convert to AnyUrl + return Resource.from_function( + fn=resource_read_fn, + uri=uri, name=self.name, description=self.description, mime_type=self.mime_type, - fn=resource_read_fn, tags=self.tags, ) diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py index f1b9ff74d..61c29bf3b 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -2,24 +2,18 @@ from __future__ import annotations -import inspect import json -from collections.abc import Callable from pathlib import Path -from typing import Any import anyio import anyio.to_thread import httpx import pydantic.json -import pydantic_core from pydantic import Field, ValidationInfo from fastmcp.exceptions import ResourceError from fastmcp.resources.resource import Resource -from fastmcp.server.dependencies import get_context from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import find_kwarg_by_type logger = get_logger(__name__) @@ -44,44 +38,6 @@ class BinaryResource(Resource): return self.data -class FunctionResource(Resource): - """A resource that defers data loading by wrapping a function. - - The function is only called when the resource is read, allowing for lazy loading - of potentially expensive data. This is particularly useful when listing resources, - as the function won't be called until the resource is actually accessed. - - The function can return: - - str for text content (default) - - bytes for binary content - - other types will be converted to JSON - """ - - fn: Callable[[], Any] - - async def read(self) -> str | bytes: - """Read the resource by calling the wrapped function.""" - from fastmcp.server.context import Context - - kwargs = {} - context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context) - if context_kwarg is not None: - kwargs[context_kwarg] = get_context() - - result = self.fn(**kwargs) - if inspect.iscoroutinefunction(self.fn): - result = await result - - if isinstance(result, Resource): - return await result.read() - elif isinstance(result, bytes): - return result - elif isinstance(result, str): - return result - else: - return pydantic_core.to_json(result, fallback=str, indent=2).decode() - - class FileResource(Resource): """A resource that reads from a file. diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index 8ebbe27c0..af69896be 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -1,7 +1,7 @@ import pytest from pydantic import AnyUrl, BaseModel -from fastmcp.resources import FunctionResource +from fastmcp.resources.resource import FunctionResource class TestFunctionResource: diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index ad3dba908..26e45b6e2 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -7,10 +7,10 @@ from pydantic import AnyUrl, FileUrl from fastmcp.exceptions import NotFoundError, ResourceError from fastmcp.resources import ( FileResource, - FunctionResource, ResourceManager, ResourceTemplate, ) +from fastmcp.resources.resource import FunctionResource @pytest.fixture diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 5bb3846a7..ffe08122a 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -5,7 +5,8 @@ import pytest from pydantic import BaseModel from fastmcp import Context -from fastmcp.resources import FunctionResource, ResourceTemplate +from fastmcp.resources import ResourceTemplate +from fastmcp.resources.resource import FunctionResource from fastmcp.resources.template import match_uri_template diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 9eb3d3721..1621d1f3e 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -1,7 +1,8 @@ import pytest from pydantic import AnyUrl -from fastmcp.resources import FunctionResource, Resource +from fastmcp.resources import Resource +from fastmcp.resources.resource import FunctionResource class TestResourceValidation: diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 12f568047..f5ef12bf3 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -5,6 +5,7 @@ import pytest from anyio import create_task_group from dirty_equals import Contains from mcp import McpError +from pydantic import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client @@ -146,9 +147,11 @@ class TestTools: class TestResources: async def test_get_resources(self, proxy_server): resources = await proxy_server.get_resources() - assert [r.name for r in resources.values()] == Contains( - "data://users", "resource://wave" + assert [r.uri for r in resources.values()] == Contains( + AnyUrl("data://users"), + AnyUrl("resource://wave"), ) + assert [r.name for r in resources.values()] == Contains("get_users", "wave") async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server): assert ( @@ -250,8 +253,12 @@ async def test_proxy_handles_multiple_concurrent_tasks_correctly( assert list(results) == Contains("resources", "prompts", "tools") assert list(results["prompts"]) == Contains("welcome") + assert [r.uri for r in results["resources"].values()] == Contains( + AnyUrl("data://users"), + AnyUrl("resource://wave"), + ) assert [r.name for r in results["resources"].values()] == Contains( - "data://users", "resource://wave" + "get_users", "wave" ) assert list(results["tools"]) == Contains( "greet", "add", "error_tool", "tool_without_description" diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 3264c0601..7c05548c9 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -20,7 +20,8 @@ from fastmcp import Client, Context, FastMCP from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import ToolError from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage -from fastmcp.resources import FileResource, FunctionResource +from fastmcp.resources import FileResource +from fastmcp.resources.resource import FunctionResource from fastmcp.utilities.types import Image