Formalize resource/functionresource replationship

This commit is contained in:
Jeremiah Lowin 2025-06-04 11:06:59 -04:00
commit 3a0faf5fcf
11 changed files with 114 additions and 65 deletions

View file

@ -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,
)

View file

@ -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()

View file

@ -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)

View file

@ -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,
)

View file

@ -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.

View file

@ -1,7 +1,7 @@
import pytest
from pydantic import AnyUrl, BaseModel
from fastmcp.resources import FunctionResource
from fastmcp.resources.resource import FunctionResource
class TestFunctionResource:

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"

View file

@ -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