Jeremiah Lowin commited on
Commit
bf2f475
·
1 Parent(s): fb71cef

permit optional resource template args

Browse files
docs/servers/resources.mdx CHANGED
@@ -183,40 +183,90 @@ from fastmcp import FastMCP
183
  mcp = FastMCP(name="DataServer")
184
 
185
  # Template URI includes {city} placeholder
186
- @mcp.resource("data://weather/{city}")
187
- # Function accepts 'city' parameter matching the placeholder
188
- def get_weather_for_city(city: str) -> dict:
189
  """Provides weather information for a specific city."""
190
- print(f"Server: Generating weather for city: {city}...")
191
- # In reality, call a weather API using the 'city' parameter
192
- temp = 20 + len(city) % 5 # Dummy logic
193
- condition = "Sunny" if len(city) % 2 == 0 else "Cloudy"
194
- return {"city": city.capitalize(), "temperature": temp, "unit": "celsius", "condition": condition}
195
-
196
- # Template with an integer parameter
197
- @mcp.resource("users://{user_id}/profile")
198
- async def get_user_profile(user_id: int) -> dict:
199
- """Retrieves a user's profile information by ID."""
200
- print(f"Server: Generating profile for user ID: {user_id}...")
201
- # In reality, fetch from database using user_id
202
- # FastMCP uses Pydantic to auto-convert the string URI part to int
203
- if user_id == 1:
204
- return {"id": user_id, "name": "Alice", "email": "alice@example.com", "status": "active"}
205
- elif user_id == 2:
206
- return {"id": user_id, "name": "Bob", "email": "bob@example.com", "status": "inactive"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  else:
208
- # Example of returning an error structure
209
- return {"error": f"User with ID {user_id} not found"}
210
  ```
211
 
 
 
 
 
 
 
 
 
 
212
  **How Templates Work:**
213
 
214
  1. **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
215
  2. **Discovery:** Clients list templates via `resources/listResourceTemplates`.
216
- 3. **Request & Matching:** A client requests a specific URI, e.g., `data://weather/london`. FastMCP matches this to the `data://weather/{city}` template.
217
  4. **Parameter Extraction:** It extracts the parameter value: `city="london"`.
218
- 5. **Type Conversion & Function Call:** It converts the extracted string `"london"` to the type hinted in the function (`str` in this case) and calls `get_weather_for_city(city="london")`. For `users://1/profile`, it converts `"1"` to `int` before calling `get_user_profile(user_id=1)`.
219
- 6. **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the content of the requested resource URI (`data://weather/london`).
 
220
 
221
  Templates provide a powerful way to expose parameterized data access points following REST-like principles.
222
 
 
183
  mcp = FastMCP(name="DataServer")
184
 
185
  # Template URI includes {city} placeholder
186
+ @mcp.resource("weather://{city}/current")
187
+ def get_weather(city: str) -> dict:
 
188
  """Provides weather information for a specific city."""
189
+ # In a real implementation, this would call a weather API
190
+ # Here we're using simplified logic for example purposes
191
+ return {
192
+ "city": city.capitalize(),
193
+ "temperature": 22,
194
+ "condition": "Sunny",
195
+ "unit": "celsius"
196
+ }
197
+
198
+ # Template with multiple parameters
199
+ @mcp.resource("repos://{owner}/{repo}/info")
200
+ def get_repo_info(owner: str, repo: str) -> dict:
201
+ """Retrieves information about a GitHub repository."""
202
+ # In a real implementation, this would call the GitHub API
203
+ return {
204
+ "owner": owner,
205
+ "name": repo,
206
+ "full_name": f"{owner}/{repo}",
207
+ "stars": 120,
208
+ "forks": 48
209
+ }
210
+ ```
211
+
212
+ With these templates defined, clients can request:
213
+ - `weather://london/current` → Returns weather for London
214
+ - `repos://fastmcp/docs/info` → Returns info about the fastmcp/docs repository
215
+
216
+ ### Parameters and Default Values
217
+
218
+ When creating resource templates, FastMCP enforces two rules for the relationship between URI template parameters and function parameters:
219
+
220
+ 1. **Required Function Parameters:** All function parameters without default values (required parameters) must appear in the URI template.
221
+ 2. **URI Parameters:** All URI template parameters must exist as function parameters.
222
+
223
+ However, function parameters with default values don't need to be included in the URI template. When a client requests a resource, FastMCP will:
224
+
225
+ - Extract parameter values from the URI for parameters included in the template
226
+ - Use default values for any function parameters not in the URI template
227
+
228
+ This allows for flexible API designs where some parameters are embedded in the URI path while others use their default values.
229
+
230
+ #### Multiple URI Templates for the Same Function
231
+
232
+ A powerful pattern is registering a single function with multiple URI templates, allowing different ways to access the same data:
233
+
234
+ ```python
235
+ from fastmcp import FastMCP
236
+
237
+ mcp = FastMCP(name="DataServer")
238
+
239
+ # Define a user lookup function that can be accessed by different identifiers
240
+ @mcp.resource("users://email/{email}")
241
+ @mcp.resource("users://name/{name}")
242
+ def lookup_user(name: str | None = None, email: str | None = None) -> dict:
243
+ """Look up a user by either name or email."""
244
+ if email:
245
+ return find_user_by_email(email) # pseudocode
246
+ elif name:
247
+ return find_user_by_name(name) # pseudocode
248
  else:
249
+ return {"error": "No lookup parameters provided"}
 
250
  ```
251
 
252
+ Now an LLM or client can retrieve user information in two different ways:
253
+ - `users://email/alice@example.com` → Looks up user by email
254
+ - `users://name/Bob` → Looks up user by name
255
+
256
+ In this pattern:
257
+ - The `name` parameter is only provided in the URI when using the `users://name/{name}` template
258
+ - The `email` parameter is only provided in the URI when using the `users://email/{email}` template
259
+ - Each parameter has a default value of `None` for when it's not in the URI
260
+
261
  **How Templates Work:**
262
 
263
  1. **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
264
  2. **Discovery:** Clients list templates via `resources/listResourceTemplates`.
265
+ 3. **Request & Matching:** A client requests a specific URI, e.g., `weather://london/current`. FastMCP matches this to the `weather://{city}/current` template.
266
  4. **Parameter Extraction:** It extracts the parameter value: `city="london"`.
267
+ 5. **Type Conversion & Function Call:** It converts extracted values to the types hinted in the function and calls `get_weather(city="london")`.
268
+ 6. **Default Values:** For any function parameters with default values not included in the URI template, FastMCP uses the default values.
269
+ 7. **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the resource content.
270
 
271
  Templates provide a powerful way to expose parameterized data access points following REST-like principles.
272
 
src/fastmcp/resources/resource_manager.py CHANGED
@@ -2,7 +2,6 @@
2
 
3
  import copy
4
  import inspect
5
- import re
6
  from collections.abc import Callable
7
  from typing import Any
8
 
@@ -52,7 +51,7 @@ class ResourceManager:
52
  has_uri_params = "{" in uri and "}" in uri
53
  has_func_params = bool(inspect.signature(fn).parameters)
54
 
55
- if has_uri_params and has_func_params:
56
  return self.add_template_from_fn(
57
  fn, uri, name, description, mime_type, tags
58
  )
@@ -138,16 +137,6 @@ class ResourceManager:
138
  ) -> ResourceTemplate:
139
  """Create a template from a function."""
140
 
141
- # Validate that URI params match function params
142
- uri_params = set(re.findall(r"{(\w+)}", uri_template))
143
- func_params = set(inspect.signature(fn).parameters.keys())
144
-
145
- if uri_params != func_params:
146
- raise ValueError(
147
- f"Mismatch between URI parameters {uri_params} "
148
- f"and function parameters {func_params}"
149
- )
150
-
151
  template = ResourceTemplate.from_function(
152
  fn,
153
  uri_template=uri_template,
 
2
 
3
  import copy
4
  import inspect
 
5
  from collections.abc import Callable
6
  from typing import Any
7
 
 
51
  has_uri_params = "{" in uri and "}" in uri
52
  has_func_params = bool(inspect.signature(fn).parameters)
53
 
54
+ if has_uri_params or has_func_params:
55
  return self.add_template_from_fn(
56
  fn, uri, name, description, mime_type, tags
57
  )
 
137
  ) -> ResourceTemplate:
138
  """Create a template from a function."""
139
 
 
 
 
 
 
 
 
 
 
 
140
  template = ResourceTemplate.from_function(
141
  fn,
142
  uri_template=uri_template,
src/fastmcp/resources/template.py CHANGED
@@ -13,6 +13,11 @@ from fastmcp.resources.types import FunctionResource, Resource
13
  from fastmcp.utilities.types import _convert_set_defaults
14
 
15
 
 
 
 
 
 
16
  class ResourceTemplate(BaseModel):
17
  """A template for dynamically creating resources."""
18
 
@@ -47,6 +52,30 @@ class ResourceTemplate(BaseModel):
47
  if func_name == "<lambda>":
48
  raise ValueError("You must provide a name for lambda functions")
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  # Get schema from TypeAdapter - will fail if function isn't properly typed
51
  parameters = TypeAdapter(fn).json_schema()
52
 
 
13
  from fastmcp.utilities.types import _convert_set_defaults
14
 
15
 
16
+ class MyModel(BaseModel):
17
+ key: str
18
+ value: int
19
+
20
+
21
  class ResourceTemplate(BaseModel):
22
  """A template for dynamically creating resources."""
23
 
 
52
  if func_name == "<lambda>":
53
  raise ValueError("You must provide a name for lambda functions")
54
 
55
+ # Validate that URI params match function params
56
+ uri_params = set(re.findall(r"{(\w+)}", uri_template))
57
+ if not uri_params:
58
+ raise ValueError("URI template must contain at least one parameter")
59
+
60
+ func_params = set(inspect.signature(fn).parameters.keys())
61
+
62
+ # get the parameters that are required
63
+ required_params = {
64
+ p
65
+ for p in func_params
66
+ if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
67
+ }
68
+
69
+ if not required_params.issubset(uri_params):
70
+ raise ValueError(
71
+ f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
72
+ )
73
+
74
+ if not uri_params.issubset(func_params):
75
+ raise ValueError(
76
+ f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
77
+ )
78
+
79
  # Get schema from TypeAdapter - will fail if function isn't properly typed
80
  parameters = TypeAdapter(fn).json_schema()
81
 
src/fastmcp/server/server.py CHANGED
@@ -363,7 +363,7 @@ class FastMCP(Generic[LifespanResultT]):
363
 
364
  def decorator(fn: AnyFunction) -> AnyFunction:
365
  self.add_tool(fn, name=name, description=description, tags=tags)
366
- return DecoratedFunction(fn)
367
 
368
  return decorator
369
 
@@ -469,7 +469,7 @@ class FastMCP(Generic[LifespanResultT]):
469
  mime_type=mime_type,
470
  tags=tags,
471
  )
472
- return DecoratedFunction(fn)
473
 
474
  return decorator
475
 
 
363
 
364
  def decorator(fn: AnyFunction) -> AnyFunction:
365
  self.add_tool(fn, name=name, description=description, tags=tags)
366
+ return fn
367
 
368
  return decorator
369
 
 
469
  mime_type=mime_type,
470
  tags=tags,
471
  )
472
+ return fn
473
 
474
  return decorator
475
 
tests/resources/test_resource_template.py CHANGED
@@ -46,6 +46,99 @@ class TestResourceTemplate:
46
  assert template.matches("test://foo") is None
47
  assert template.matches("other://foo/123") is None
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  @pytest.mark.anyio
50
  async def test_create_resource(self):
51
  """Test creating a resource from a template."""
 
46
  assert template.matches("test://foo") is None
47
  assert template.matches("other://foo/123") is None
48
 
49
+ def test_template_uri_validation(self):
50
+ """Test validation rule: URI template must have at least one parameter."""
51
+
52
+ def my_func() -> dict:
53
+ return {"data": "value"}
54
+
55
+ with pytest.raises(
56
+ ValueError, match="URI template must contain at least one parameter"
57
+ ):
58
+ ResourceTemplate.from_function(
59
+ fn=my_func,
60
+ uri_template="test://no-params",
61
+ name="test",
62
+ )
63
+
64
+ def test_template_uri_params_subset_of_function_params(self):
65
+ """Test validation rule: URI parameters must be a subset of function parameters."""
66
+
67
+ def my_func(key: str, value: int) -> dict:
68
+ return {"key": key, "value": value}
69
+
70
+ # This should work - URI params are a subset of function params
71
+ template = ResourceTemplate.from_function(
72
+ fn=my_func,
73
+ uri_template="test://{key}/{value}",
74
+ name="test",
75
+ )
76
+ assert template.uri_template == "test://{key}/{value}"
77
+
78
+ # This should fail - 'unknown' is not a function parameter
79
+ with pytest.raises(
80
+ ValueError,
81
+ match="URI parameters .* must be a subset of the required function arguments",
82
+ ):
83
+ ResourceTemplate.from_function(
84
+ fn=my_func,
85
+ uri_template="test://{key}/{unknown}",
86
+ name="test",
87
+ )
88
+
89
+ def test_required_params_subset_of_uri_params(self):
90
+ """Test validation rule: Required function parameters must be in URI parameters."""
91
+
92
+ # Function with required parameters
93
+ def func_with_required(
94
+ required_param: str, optional_param: str = "default"
95
+ ) -> dict:
96
+ return {"required": required_param, "optional": optional_param}
97
+
98
+ # This should work - required param is in URI
99
+ template = ResourceTemplate.from_function(
100
+ fn=func_with_required,
101
+ uri_template="test://{required_param}",
102
+ name="test",
103
+ )
104
+ assert template.uri_template == "test://{required_param}"
105
+
106
+ # This should fail - required param is not in URI
107
+ with pytest.raises(
108
+ ValueError,
109
+ match="URI parameters .* must be a subset of the required function arguments",
110
+ ):
111
+ ResourceTemplate.from_function(
112
+ fn=func_with_required,
113
+ uri_template="test://{optional_param}",
114
+ name="test",
115
+ )
116
+
117
+ def test_multiple_required_params(self):
118
+ """Test validation with multiple required parameters."""
119
+
120
+ def multi_required(param1: str, param2: int, optional: str = "default") -> dict:
121
+ return {"p1": param1, "p2": param2, "opt": optional}
122
+
123
+ # This works - all required params in URI
124
+ template = ResourceTemplate.from_function(
125
+ fn=multi_required,
126
+ uri_template="test://{param1}/{param2}",
127
+ name="test",
128
+ )
129
+ assert template.uri_template == "test://{param1}/{param2}"
130
+
131
+ # This fails - missing one required param
132
+ with pytest.raises(
133
+ ValueError,
134
+ match="URI parameters .* must be a subset of the required function arguments",
135
+ ):
136
+ ResourceTemplate.from_function(
137
+ fn=multi_required,
138
+ uri_template="test://{param1}",
139
+ name="test",
140
+ )
141
+
142
  @pytest.mark.anyio
143
  async def test_create_resource(self):
144
  """Test creating a resource from a template."""
tests/server/test_server.py CHANGED
@@ -840,22 +840,28 @@ class TestServerResources:
840
 
841
 
842
  class TestServerResourceTemplates:
843
- async def test_resource_with_params(self):
844
  """Test that a resource with function parameters raises an error if the URI
845
  parameters don't match"""
846
  mcp = FastMCP()
847
 
848
- with pytest.raises(ValueError, match="mismatch between URI parameters"):
 
 
 
849
 
850
  @mcp.resource("resource://data")
851
  def get_data_fn(param: str) -> str:
852
  return f"Data: {param}"
853
 
854
- async def test_resource_with_uri_params(self):
855
  """Test that a resource with URI parameters is automatically a template"""
856
  mcp = FastMCP()
857
 
858
- with pytest.raises(ValueError, match="mismatch between URI parameters"):
 
 
 
859
 
860
  @mcp.resource("resource://{param}")
861
  def get_data() -> str:
@@ -886,7 +892,10 @@ class TestServerResourceTemplates:
886
  """Test that mismatched parameters raise an error"""
887
  mcp = FastMCP()
888
 
889
- with pytest.raises(ValueError, match="Mismatch between URI parameters"):
 
 
 
890
 
891
  @mcp.resource("resource://{name}/data")
892
  def get_data(user: str) -> str:
@@ -911,7 +920,10 @@ class TestServerResourceTemplates:
911
  """Test that mismatched parameters raise an error"""
912
  mcp = FastMCP()
913
 
914
- with pytest.raises(ValueError, match="Mismatch between URI parameters"):
 
 
 
915
 
916
  @mcp.resource("resource://{org}/{repo}/data")
917
  def get_data_mismatched(org: str, repo_2: str) -> str:
@@ -929,6 +941,32 @@ class TestServerResourceTemplates:
929
  assert isinstance(result[0], TextResourceContents)
930
  assert result[0].text == "Static data"
931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  async def test_template_to_resource_conversion(self):
933
  """Test that templates are properly converted to resources when accessed"""
934
  mcp = FastMCP()
@@ -947,6 +985,54 @@ class TestServerResourceTemplates:
947
  result = await resource.read()
948
  assert result == "Data for test"
949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
950
 
951
  class TestContextInjection:
952
  """Test context injection in tools."""
 
840
 
841
 
842
  class TestServerResourceTemplates:
843
+ async def test_resource_with_params_not_in_uri(self):
844
  """Test that a resource with function parameters raises an error if the URI
845
  parameters don't match"""
846
  mcp = FastMCP()
847
 
848
+ with pytest.raises(
849
+ ValueError,
850
+ match="URI template must contain at least one parameter",
851
+ ):
852
 
853
  @mcp.resource("resource://data")
854
  def get_data_fn(param: str) -> str:
855
  return f"Data: {param}"
856
 
857
+ async def test_resource_with_uri_params_without_args(self):
858
  """Test that a resource with URI parameters is automatically a template"""
859
  mcp = FastMCP()
860
 
861
+ with pytest.raises(
862
+ ValueError,
863
+ match="URI parameters .* must be a subset of the function arguments",
864
+ ):
865
 
866
  @mcp.resource("resource://{param}")
867
  def get_data() -> str:
 
892
  """Test that mismatched parameters raise an error"""
893
  mcp = FastMCP()
894
 
895
+ with pytest.raises(
896
+ ValueError,
897
+ match="URI parameters .* must be a subset of the required function arguments",
898
+ ):
899
 
900
  @mcp.resource("resource://{name}/data")
901
  def get_data(user: str) -> str:
 
920
  """Test that mismatched parameters raise an error"""
921
  mcp = FastMCP()
922
 
923
+ with pytest.raises(
924
+ ValueError,
925
+ match="URI parameters .* must be a subset of the required function arguments",
926
+ ):
927
 
928
  @mcp.resource("resource://{org}/{repo}/data")
929
  def get_data_mismatched(org: str, repo_2: str) -> str:
 
941
  assert isinstance(result[0], TextResourceContents)
942
  assert result[0].text == "Static data"
943
 
944
+ async def test_template_with_default_params(self):
945
+ """Test that a template with default function parameters works when those parameters
946
+ are not in the URI template"""
947
+ mcp = FastMCP()
948
+
949
+ @mcp.resource("math://add/{x}")
950
+ def add(x: int, y: int = 10) -> int:
951
+ return x + y
952
+
953
+ # Verify it's registered as a template
954
+ templates = mcp.list_resource_templates()
955
+ assert len(templates) == 1
956
+ assert templates[0].uri_template == "math://add/{x}"
957
+
958
+ # Call the template and verify it uses the default value
959
+ async with Client(mcp) as client:
960
+ result = await client.read_resource(AnyUrl("math://add/5"))
961
+ assert isinstance(result[0], TextResourceContents)
962
+ assert result[0].text == "15" # 5 + default 10
963
+
964
+ # Can also call with explicit params
965
+ resource = await mcp._resource_manager.get_resource("math://add/7")
966
+ assert isinstance(resource, FunctionResource)
967
+ result = await resource.read()
968
+ assert result == "17" # 7 + default 10
969
+
970
  async def test_template_to_resource_conversion(self):
971
  """Test that templates are properly converted to resources when accessed"""
972
  mcp = FastMCP()
 
985
  result = await resource.read()
986
  assert result == "Data for test"
987
 
988
+ async def test_stacked_resource_template_decorators(self):
989
+ """Test that multiple resource decorators can be stacked on the same function."""
990
+ mcp = FastMCP()
991
+
992
+ # Define a function with multiple stacked resource decorators
993
+ @mcp.resource("users://email/{email}")
994
+ @mcp.resource("users://name/{name}")
995
+ def lookup_user(name: str | None = None, email: str | None = None) -> dict:
996
+ """Look up a user by either name or email."""
997
+ # In a real implementation, this would query a database
998
+ if email:
999
+ return {
1000
+ "found_by": "email",
1001
+ "name": f"User for {email}",
1002
+ "email": email,
1003
+ }
1004
+ else:
1005
+ return {
1006
+ "found_by": "name",
1007
+ "name": name,
1008
+ "email": f"{name.lower()}@example.com" if name else None,
1009
+ }
1010
+
1011
+ # Verify both templates are registered
1012
+ templates = mcp.list_resource_templates()
1013
+ assert len(templates) == 2
1014
+ template_uris = {t.uri_template for t in templates}
1015
+ assert "users://email/{email}" in template_uris
1016
+ assert "users://name/{name}" in template_uris
1017
+
1018
+ # Test lookup by email
1019
+ async with Client(mcp) as client:
1020
+ email_result = await client.read_resource(
1021
+ AnyUrl("users://email/user@example.com")
1022
+ )
1023
+ assert isinstance(email_result[0], TextResourceContents)
1024
+ email_data = json.loads(email_result[0].text)
1025
+ assert email_data["found_by"] == "email"
1026
+ assert email_data["email"] == "user@example.com"
1027
+
1028
+ # Test lookup by name
1029
+ name_result = await client.read_resource(AnyUrl("users://name/John"))
1030
+ assert isinstance(name_result[0], TextResourceContents)
1031
+ name_data = json.loads(name_result[0].text)
1032
+ assert name_data["found_by"] == "name"
1033
+ assert name_data["name"] == "John"
1034
+ assert name_data["email"] == "john@example.com"
1035
+
1036
 
1037
  class TestContextInjection:
1038
  """Test context injection in tools."""