Jeremiah Lowin commited on
Commit
582f82a
·
1 Parent(s): 35489c1

Formalize resource/functionresource replationship

Browse files
src/fastmcp/resources/__init__.py CHANGED
@@ -1,10 +1,9 @@
1
- from .resource import Resource
2
  from .template import ResourceTemplate
3
  from .types import (
4
  BinaryResource,
5
  DirectoryResource,
6
  FileResource,
7
- FunctionResource,
8
  HttpResource,
9
  TextResource,
10
  )
 
1
+ from .resource import FunctionResource, Resource
2
  from .template import ResourceTemplate
3
  from .types import (
4
  BinaryResource,
5
  DirectoryResource,
6
  FileResource,
 
7
  HttpResource,
8
  TextResource,
9
  )
src/fastmcp/resources/resource.py CHANGED
@@ -3,8 +3,11 @@
3
  from __future__ import annotations
4
 
5
  import abc
 
 
6
  from typing import TYPE_CHECKING, Annotated, Any
7
 
 
8
  from mcp.types import Resource as MCPResource
9
  from pydantic import (
10
  AnyUrl,
@@ -16,7 +19,12 @@ from pydantic import (
16
  field_validator,
17
  )
18
 
19
- from fastmcp.utilities.types import FastMCPBaseModel, _convert_set_defaults
 
 
 
 
 
20
 
21
  if TYPE_CHECKING:
22
  pass
@@ -43,6 +51,24 @@ class Resource(FastMCPBaseModel, abc.ABC):
43
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
44
  )
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  @field_validator("mime_type", mode="before")
47
  @classmethod
48
  def set_default_mime_type(cls, mime_type: str | None) -> str:
@@ -80,3 +106,63 @@ class Resource(FastMCPBaseModel, abc.ABC):
80
  "mimeType": self.mime_type,
81
  }
82
  return MCPResource(**kwargs | overrides)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from __future__ import annotations
4
 
5
  import abc
6
+ import inspect
7
+ from collections.abc import Callable
8
  from typing import TYPE_CHECKING, Annotated, Any
9
 
10
+ import pydantic_core
11
  from mcp.types import Resource as MCPResource
12
  from pydantic import (
13
  AnyUrl,
 
19
  field_validator,
20
  )
21
 
22
+ from fastmcp.server.dependencies import get_context
23
+ from fastmcp.utilities.types import (
24
+ FastMCPBaseModel,
25
+ _convert_set_defaults,
26
+ find_kwarg_by_type,
27
+ )
28
 
29
  if TYPE_CHECKING:
30
  pass
 
51
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
52
  )
53
 
54
+ @staticmethod
55
+ def from_function(
56
+ fn: Callable[[], Any],
57
+ uri: str | AnyUrl,
58
+ name: str | None = None,
59
+ description: str | None = None,
60
+ mime_type: str | None = None,
61
+ tags: set[str] | None = None,
62
+ ) -> FunctionResource:
63
+ return FunctionResource.from_function(
64
+ fn=fn,
65
+ uri=uri,
66
+ name=name,
67
+ description=description,
68
+ mime_type=mime_type,
69
+ tags=tags,
70
+ )
71
+
72
  @field_validator("mime_type", mode="before")
73
  @classmethod
74
  def set_default_mime_type(cls, mime_type: str | None) -> str:
 
106
  "mimeType": self.mime_type,
107
  }
108
  return MCPResource(**kwargs | overrides)
109
+
110
+
111
+ class FunctionResource(Resource):
112
+ """A resource that defers data loading by wrapping a function.
113
+
114
+ The function is only called when the resource is read, allowing for lazy loading
115
+ of potentially expensive data. This is particularly useful when listing resources,
116
+ as the function won't be called until the resource is actually accessed.
117
+
118
+ The function can return:
119
+ - str for text content (default)
120
+ - bytes for binary content
121
+ - other types will be converted to JSON
122
+ """
123
+
124
+ fn: Callable[[], Any]
125
+
126
+ @classmethod
127
+ def from_function(
128
+ cls,
129
+ fn: Callable[[], Any],
130
+ uri: str | AnyUrl,
131
+ name: str | None = None,
132
+ description: str | None = None,
133
+ mime_type: str | None = None,
134
+ tags: set[str] | None = None,
135
+ ) -> FunctionResource:
136
+ """Create a FunctionResource from a function."""
137
+ if isinstance(uri, str):
138
+ uri = AnyUrl(uri)
139
+ return cls(
140
+ fn=fn,
141
+ uri=uri,
142
+ name=name or fn.__name__,
143
+ description=description or fn.__doc__,
144
+ mime_type=mime_type or "text/plain",
145
+ tags=tags or set(),
146
+ )
147
+
148
+ async def read(self) -> str | bytes:
149
+ """Read the resource by calling the wrapped function."""
150
+ from fastmcp.server.context import Context
151
+
152
+ kwargs = {}
153
+ context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
154
+ if context_kwarg is not None:
155
+ kwargs[context_kwarg] = get_context()
156
+
157
+ result = self.fn(**kwargs)
158
+ if inspect.iscoroutinefunction(self.fn):
159
+ result = await result
160
+
161
+ if isinstance(result, Resource):
162
+ return await result.read()
163
+ elif isinstance(result, bytes):
164
+ return result
165
+ elif isinstance(result, str):
166
+ return result
167
+ else:
168
+ return pydantic_core.to_json(result, fallback=str, indent=2).decode()
src/fastmcp/resources/resource_manager.py CHANGED
@@ -7,7 +7,6 @@ from typing import Any
7
  from pydantic import AnyUrl
8
 
9
  from fastmcp.exceptions import NotFoundError, ResourceError
10
- from fastmcp.resources import FunctionResource
11
  from fastmcp.resources.resource import Resource
12
  from fastmcp.resources.template import (
13
  ResourceTemplate,
@@ -121,13 +120,13 @@ class ResourceManager:
121
  The added resource. If a resource with the same URI already exists,
122
  returns the existing resource.
123
  """
124
- resource = FunctionResource(
125
  fn=fn,
126
- uri=AnyUrl(uri),
127
  name=name,
128
  description=description,
129
- mime_type=mime_type or "text/plain",
130
- tags=tags or set(),
131
  )
132
  return self.add_resource(resource)
133
 
 
7
  from pydantic import AnyUrl
8
 
9
  from fastmcp.exceptions import NotFoundError, ResourceError
 
10
  from fastmcp.resources.resource import Resource
11
  from fastmcp.resources.template import (
12
  ResourceTemplate,
 
120
  The added resource. If a resource with the same URI already exists,
121
  returns the existing resource.
122
  """
123
+ resource = Resource.from_function(
124
  fn=fn,
125
+ uri=uri,
126
  name=name,
127
  description=description,
128
+ mime_type=mime_type,
129
+ tags=tags,
130
  )
131
  return self.add_resource(resource)
132
 
src/fastmcp/resources/template.py CHANGED
@@ -10,14 +10,13 @@ from urllib.parse import unquote
10
 
11
  from mcp.types import ResourceTemplate as MCPResourceTemplate
12
  from pydantic import (
13
- AnyUrl,
14
  BeforeValidator,
15
  Field,
16
  field_validator,
17
  validate_call,
18
  )
19
 
20
- from fastmcp.resources.types import FunctionResource, Resource
21
  from fastmcp.server.dependencies import get_context
22
  from fastmcp.utilities.json_schema import compress_schema
23
  from fastmcp.utilities.types import (
@@ -189,12 +188,12 @@ class ResourceTemplate(FastMCPBaseModel):
189
  result = await result
190
  return result
191
 
192
- return FunctionResource(
193
- uri=AnyUrl(uri), # Explicitly convert to AnyUrl
 
194
  name=self.name,
195
  description=self.description,
196
  mime_type=self.mime_type,
197
- fn=resource_read_fn,
198
  tags=self.tags,
199
  )
200
 
 
10
 
11
  from mcp.types import ResourceTemplate as MCPResourceTemplate
12
  from pydantic import (
 
13
  BeforeValidator,
14
  Field,
15
  field_validator,
16
  validate_call,
17
  )
18
 
19
+ from fastmcp.resources.types import Resource
20
  from fastmcp.server.dependencies import get_context
21
  from fastmcp.utilities.json_schema import compress_schema
22
  from fastmcp.utilities.types import (
 
188
  result = await result
189
  return result
190
 
191
+ return Resource.from_function(
192
+ fn=resource_read_fn,
193
+ uri=uri,
194
  name=self.name,
195
  description=self.description,
196
  mime_type=self.mime_type,
 
197
  tags=self.tags,
198
  )
199
 
src/fastmcp/resources/types.py CHANGED
@@ -2,24 +2,18 @@
2
 
3
  from __future__ import annotations
4
 
5
- import inspect
6
  import json
7
- from collections.abc import Callable
8
  from pathlib import Path
9
- from typing import Any
10
 
11
  import anyio
12
  import anyio.to_thread
13
  import httpx
14
  import pydantic.json
15
- import pydantic_core
16
  from pydantic import Field, ValidationInfo
17
 
18
  from fastmcp.exceptions import ResourceError
19
  from fastmcp.resources.resource import Resource
20
- from fastmcp.server.dependencies import get_context
21
  from fastmcp.utilities.logging import get_logger
22
- from fastmcp.utilities.types import find_kwarg_by_type
23
 
24
  logger = get_logger(__name__)
25
 
@@ -44,44 +38,6 @@ class BinaryResource(Resource):
44
  return self.data
45
 
46
 
47
- class FunctionResource(Resource):
48
- """A resource that defers data loading by wrapping a function.
49
-
50
- The function is only called when the resource is read, allowing for lazy loading
51
- of potentially expensive data. This is particularly useful when listing resources,
52
- as the function won't be called until the resource is actually accessed.
53
-
54
- The function can return:
55
- - str for text content (default)
56
- - bytes for binary content
57
- - other types will be converted to JSON
58
- """
59
-
60
- fn: Callable[[], Any]
61
-
62
- async def read(self) -> str | bytes:
63
- """Read the resource by calling the wrapped function."""
64
- from fastmcp.server.context import Context
65
-
66
- kwargs = {}
67
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
68
- if context_kwarg is not None:
69
- kwargs[context_kwarg] = get_context()
70
-
71
- result = self.fn(**kwargs)
72
- if inspect.iscoroutinefunction(self.fn):
73
- result = await result
74
-
75
- if isinstance(result, Resource):
76
- return await result.read()
77
- elif isinstance(result, bytes):
78
- return result
79
- elif isinstance(result, str):
80
- return result
81
- else:
82
- return pydantic_core.to_json(result, fallback=str, indent=2).decode()
83
-
84
-
85
  class FileResource(Resource):
86
  """A resource that reads from a file.
87
 
 
2
 
3
  from __future__ import annotations
4
 
 
5
  import json
 
6
  from pathlib import Path
 
7
 
8
  import anyio
9
  import anyio.to_thread
10
  import httpx
11
  import pydantic.json
 
12
  from pydantic import Field, ValidationInfo
13
 
14
  from fastmcp.exceptions import ResourceError
15
  from fastmcp.resources.resource import Resource
 
16
  from fastmcp.utilities.logging import get_logger
 
17
 
18
  logger = get_logger(__name__)
19
 
 
38
  return self.data
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  class FileResource(Resource):
42
  """A resource that reads from a file.
43
 
tests/resources/test_function_resources.py CHANGED
@@ -1,7 +1,7 @@
1
  import pytest
2
  from pydantic import AnyUrl, BaseModel
3
 
4
- from fastmcp.resources import FunctionResource
5
 
6
 
7
  class TestFunctionResource:
 
1
  import pytest
2
  from pydantic import AnyUrl, BaseModel
3
 
4
+ from fastmcp.resources.resource import FunctionResource
5
 
6
 
7
  class TestFunctionResource:
tests/resources/test_resource_manager.py CHANGED
@@ -7,10 +7,10 @@ from pydantic import AnyUrl, FileUrl
7
  from fastmcp.exceptions import NotFoundError, ResourceError
8
  from fastmcp.resources import (
9
  FileResource,
10
- FunctionResource,
11
  ResourceManager,
12
  ResourceTemplate,
13
  )
 
14
 
15
 
16
  @pytest.fixture
 
7
  from fastmcp.exceptions import NotFoundError, ResourceError
8
  from fastmcp.resources import (
9
  FileResource,
 
10
  ResourceManager,
11
  ResourceTemplate,
12
  )
13
+ from fastmcp.resources.resource import FunctionResource
14
 
15
 
16
  @pytest.fixture
tests/resources/test_resource_template.py CHANGED
@@ -5,7 +5,8 @@ import pytest
5
  from pydantic import BaseModel
6
 
7
  from fastmcp import Context
8
- from fastmcp.resources import FunctionResource, ResourceTemplate
 
9
  from fastmcp.resources.template import match_uri_template
10
 
11
 
 
5
  from pydantic import BaseModel
6
 
7
  from fastmcp import Context
8
+ from fastmcp.resources import ResourceTemplate
9
+ from fastmcp.resources.resource import FunctionResource
10
  from fastmcp.resources.template import match_uri_template
11
 
12
 
tests/resources/test_resources.py CHANGED
@@ -1,7 +1,8 @@
1
  import pytest
2
  from pydantic import AnyUrl
3
 
4
- from fastmcp.resources import FunctionResource, Resource
 
5
 
6
 
7
  class TestResourceValidation:
 
1
  import pytest
2
  from pydantic import AnyUrl
3
 
4
+ from fastmcp.resources import Resource
5
+ from fastmcp.resources.resource import FunctionResource
6
 
7
 
8
  class TestResourceValidation:
tests/server/test_proxy.py CHANGED
@@ -5,6 +5,7 @@ import pytest
5
  from anyio import create_task_group
6
  from dirty_equals import Contains
7
  from mcp import McpError
 
8
 
9
  from fastmcp import FastMCP
10
  from fastmcp.client import Client
@@ -146,9 +147,11 @@ class TestTools:
146
  class TestResources:
147
  async def test_get_resources(self, proxy_server):
148
  resources = await proxy_server.get_resources()
149
- assert [r.name for r in resources.values()] == Contains(
150
- "data://users", "resource://wave"
 
151
  )
 
152
 
153
  async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
154
  assert (
@@ -250,8 +253,12 @@ async def test_proxy_handles_multiple_concurrent_tasks_correctly(
250
 
251
  assert list(results) == Contains("resources", "prompts", "tools")
252
  assert list(results["prompts"]) == Contains("welcome")
 
 
 
 
253
  assert [r.name for r in results["resources"].values()] == Contains(
254
- "data://users", "resource://wave"
255
  )
256
  assert list(results["tools"]) == Contains(
257
  "greet", "add", "error_tool", "tool_without_description"
 
5
  from anyio import create_task_group
6
  from dirty_equals import Contains
7
  from mcp import McpError
8
+ from pydantic import AnyUrl
9
 
10
  from fastmcp import FastMCP
11
  from fastmcp.client import Client
 
147
  class TestResources:
148
  async def test_get_resources(self, proxy_server):
149
  resources = await proxy_server.get_resources()
150
+ assert [r.uri for r in resources.values()] == Contains(
151
+ AnyUrl("data://users"),
152
+ AnyUrl("resource://wave"),
153
  )
154
+ assert [r.name for r in resources.values()] == Contains("get_users", "wave")
155
 
156
  async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
157
  assert (
 
253
 
254
  assert list(results) == Contains("resources", "prompts", "tools")
255
  assert list(results["prompts"]) == Contains("welcome")
256
+ assert [r.uri for r in results["resources"].values()] == Contains(
257
+ AnyUrl("data://users"),
258
+ AnyUrl("resource://wave"),
259
+ )
260
  assert [r.name for r in results["resources"].values()] == Contains(
261
+ "get_users", "wave"
262
  )
263
  assert list(results["tools"]) == Contains(
264
  "greet", "add", "error_tool", "tool_without_description"
tests/server/test_server_interactions.py CHANGED
@@ -20,7 +20,8 @@ from fastmcp import Client, Context, FastMCP
20
  from fastmcp.client.transports import FastMCPTransport
21
  from fastmcp.exceptions import ToolError
22
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
23
- from fastmcp.resources import FileResource, FunctionResource
 
24
  from fastmcp.utilities.types import Image
25
 
26
 
 
20
  from fastmcp.client.transports import FastMCPTransport
21
  from fastmcp.exceptions import ToolError
22
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
23
+ from fastmcp.resources import FileResource
24
+ from fastmcp.resources.resource import FunctionResource
25
  from fastmcp.utilities.types import Image
26
 
27