mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Formalize resource/functionresource replationship
This commit is contained in:
parent
7cf2adc136
commit
3a0faf5fcf
11 changed files with 114 additions and 65 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pytest
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from fastmcp.resources import FunctionResource
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
|
||||
|
||||
class TestFunctionResource:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue