fastmcp 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. fastmcp-2.1.0/.cursor/rules/core-mcp-objects.mdc +13 -0
  2. {fastmcp-2.0.0 → fastmcp-2.1.0}/PKG-INFO +1 -1
  3. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/mount_example.py +3 -2
  4. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/cli/cli.py +2 -2
  5. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/client/client.py +80 -35
  6. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/prompts/__init__.py +1 -1
  7. fastmcp-2.0.0/src/fastmcp/prompts/base.py → fastmcp-2.1.0/src/fastmcp/prompts/prompt.py +23 -3
  8. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/prompts/prompt_manager.py +27 -11
  9. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/resources/__init__.py +2 -2
  10. fastmcp-2.0.0/src/fastmcp/resources/base.py → fastmcp-2.1.0/src/fastmcp/resources/resource.py +20 -1
  11. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/resources/resource_manager.py +57 -15
  12. fastmcp-2.0.0/src/fastmcp/resources/templates.py → fastmcp-2.1.0/src/fastmcp/resources/template.py +23 -3
  13. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/resources/types.py +2 -2
  14. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/server/openapi.py +16 -4
  15. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/server/proxy.py +27 -23
  16. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/server/server.py +97 -30
  17. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/settings.py +11 -3
  18. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/tools/__init__.py +1 -1
  19. fastmcp-2.0.0/src/fastmcp/tools/base.py → fastmcp-2.1.0/src/fastmcp/tools/tool.py +22 -3
  20. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/tools/tool_manager.py +22 -16
  21. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/utilities/types.py +12 -0
  22. fastmcp-2.0.0/tests/client/test_fastmcp_transport.py → fastmcp-2.1.0/tests/client/test_client.py +11 -11
  23. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/client/test_roots.py +2 -2
  24. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/client/test_sampling.py +3 -3
  25. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/prompts/test_base.py +1 -1
  26. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/prompts/test_prompt_manager.py +141 -81
  27. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/resources/test_resource_manager.py +196 -2
  28. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_mount.py +59 -11
  29. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_openapi.py +99 -4
  30. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_server.py +216 -208
  31. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/tools/test_tool_manager.py +158 -42
  32. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/openapi/test_openapi.py +57 -0
  33. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/openapi/test_openapi_fastapi.py +88 -0
  34. {fastmcp-2.0.0 → fastmcp-2.1.0}/.github/release.yml +0 -0
  35. {fastmcp-2.0.0 → fastmcp-2.1.0}/.github/workflows/publish.yml +0 -0
  36. {fastmcp-2.0.0 → fastmcp-2.1.0}/.github/workflows/run-static.yml +0 -0
  37. {fastmcp-2.0.0 → fastmcp-2.1.0}/.github/workflows/run-tests.yml +0 -0
  38. {fastmcp-2.0.0 → fastmcp-2.1.0}/.gitignore +0 -0
  39. {fastmcp-2.0.0 → fastmcp-2.1.0}/.pre-commit-config.yaml +0 -0
  40. {fastmcp-2.0.0 → fastmcp-2.1.0}/LICENSE +0 -0
  41. {fastmcp-2.0.0 → fastmcp-2.1.0}/README.md +0 -0
  42. {fastmcp-2.0.0 → fastmcp-2.1.0}/Windows_Notes.md +0 -0
  43. {fastmcp-2.0.0 → fastmcp-2.1.0}/docs/assets/demo-inspector.png +0 -0
  44. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/complex_inputs.py +0 -0
  45. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/desktop.py +0 -0
  46. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/echo.py +0 -0
  47. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/memory.py +0 -0
  48. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/readme-quickstart.py +0 -0
  49. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/sampling.py +0 -0
  50. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/screenshot.py +0 -0
  51. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/simple_echo.py +0 -0
  52. {fastmcp-2.0.0 → fastmcp-2.1.0}/examples/text_me.py +0 -0
  53. {fastmcp-2.0.0 → fastmcp-2.1.0}/pyproject.toml +0 -0
  54. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/__init__.py +0 -0
  55. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/cli/__init__.py +0 -0
  56. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/cli/claude.py +0 -0
  57. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/client/__init__.py +0 -0
  58. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/client/base.py +0 -0
  59. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/client/roots.py +0 -0
  60. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/client/sampling.py +0 -0
  61. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/client/transports.py +0 -0
  62. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/exceptions.py +0 -0
  63. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/py.typed +0 -0
  64. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/server/__init__.py +0 -0
  65. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/server/context.py +0 -0
  66. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/utilities/__init__.py +0 -0
  67. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/utilities/func_metadata.py +0 -0
  68. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/utilities/logging.py +0 -0
  69. {fastmcp-2.0.0 → fastmcp-2.1.0}/src/fastmcp/utilities/openapi.py +0 -0
  70. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/__init__.py +0 -0
  71. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/client/__init__.py +0 -0
  72. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/prompts/__init__.py +0 -0
  73. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/resources/__init__.py +0 -0
  74. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/resources/test_file_resources.py +0 -0
  75. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/resources/test_function_resources.py +0 -0
  76. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/resources/test_resource_template.py +0 -0
  77. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/resources/test_resources.py +0 -0
  78. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/__init__.py +0 -0
  79. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_file_server.py +0 -0
  80. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_lifespan.py +0 -0
  81. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_proxy.py +0 -0
  82. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_run_server.py +0 -0
  83. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_servers/fastmcp_server.py +0 -0
  84. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_servers/sse.py +0 -0
  85. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/server/test_servers/stdio.py +0 -0
  86. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/tools/__init__.py +0 -0
  87. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/__init__.py +0 -0
  88. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/openapi/__init__.py +0 -0
  89. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/openapi/conftest.py +0 -0
  90. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/openapi/test_openapi_advanced.py +0 -0
  91. {fastmcp-2.0.0 → fastmcp-2.1.0}/tests/utilities/test_func_metadata.py +0 -0
  92. {fastmcp-2.0.0 → fastmcp-2.1.0}/uv.lock +0 -0
@@ -0,0 +1,13 @@
1
+ ---
2
+ description:
3
+ globs:
4
+ alwaysApply: true
5
+ ---
6
+ There are four major MCP object types:
7
+
8
+ - Tools (src/tools/)
9
+ - Resources (src/resources/)
10
+ - Resource Templates (src/resources/)
11
+ - Prompts (src/prompts)
12
+
13
+ While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Be sure to look at not only the object definition but also the related `Manager` (e.g. `ToolManager`, `ResourceManager`, and `PromptManager`). Also note that while resources and resource templates are different objects, they both are handled by the `ResourceManager`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastmcp
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: An ergonomic MCP interface
5
5
  Author: Jeremiah Lowin
6
6
  License: Apache-2.0
@@ -64,10 +64,11 @@ def check_app_status() -> dict[str, str]:
64
64
 
65
65
  # Mount sub-applications
66
66
  app.mount("weather", weather_app)
67
+
67
68
  app.mount("news", news_app)
68
69
 
69
70
 
70
- async def start_server():
71
+ async def get_server_details():
71
72
  """Print information about mounted resources."""
72
73
  # Print available tools
73
74
  tools = app._tool_manager.list_tools()
@@ -105,7 +106,7 @@ async def start_server():
105
106
 
106
107
  if __name__ == "__main__":
107
108
  # First run our async function to display info
108
- asyncio.run(start_server())
109
+ asyncio.run(get_server_details())
109
110
 
110
111
  # Then start the server (uncomment to run the server)
111
112
  # app.run()
@@ -65,7 +65,7 @@ def _build_uv_command(
65
65
  """Build the uv run command that runs a MCP server through mcp run."""
66
66
  cmd = ["uv"]
67
67
 
68
- cmd.extend(["run", "--with", "mcp"])
68
+ cmd.extend(["run", "--with", "fastmcp"])
69
69
 
70
70
  if with_editable:
71
71
  cmd.extend(["--with-editable", str(with_editable)])
@@ -76,7 +76,7 @@ def _build_uv_command(
76
76
  cmd.extend(["--with", pkg])
77
77
 
78
78
  # Add mcp run command
79
- cmd.extend(["mcp", "run", file_spec])
79
+ cmd.extend(["fastmcp", "run", file_spec])
80
80
  return cmd
81
81
 
82
82
 
@@ -1,7 +1,7 @@
1
1
  import datetime
2
2
  from contextlib import AbstractAsyncContextManager
3
3
  from pathlib import Path
4
- from typing import Any
4
+ from typing import Any, Literal, cast, overload
5
5
 
6
6
  import mcp.types
7
7
  from mcp import ClientSession
@@ -24,6 +24,10 @@ from .transports import ClientTransport, SessionKwargs, infer_transport
24
24
  __all__ = ["Client", "RootsHandler", "RootsList"]
25
25
 
26
26
 
27
+ class ClientError(ValueError):
28
+ """Base class for errors raised by the client."""
29
+
30
+
27
31
  class Client:
28
32
  """
29
33
  MCP client that delegates connection management to a Transport instance.
@@ -122,60 +126,101 @@ class Client:
122
126
  """Send a logging/setLevel request."""
123
127
  await self.session.set_logging_level(level)
124
128
 
125
- async def list_resources(self) -> mcp.types.ListResourcesResult:
129
+ async def send_roots_list_changed(self) -> None:
130
+ """Send a roots/list_changed notification."""
131
+ await self.session.send_roots_list_changed()
132
+
133
+ async def list_resources(self) -> list[mcp.types.Resource]:
126
134
  """Send a resources/list request."""
127
- return await self.session.list_resources()
135
+ result = await self.session.list_resources()
136
+ return result.resources
128
137
 
129
- async def list_resource_templates(self) -> mcp.types.ListResourceTemplatesResult:
138
+ async def list_resource_templates(self) -> list[mcp.types.ResourceTemplate]:
130
139
  """Send a resources/listResourceTemplates request."""
131
- return await self.session.list_resource_templates()
140
+ result = await self.session.list_resource_templates()
141
+ return result.resourceTemplates
132
142
 
133
- async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
143
+ async def read_resource(
144
+ self, uri: AnyUrl | str
145
+ ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
134
146
  """Send a resources/read request."""
135
147
  if isinstance(uri, str):
136
148
  uri = AnyUrl(uri) # Ensure AnyUrl
137
- return await self.session.read_resource(uri)
138
-
139
- async def subscribe_resource(self, uri: AnyUrl | str) -> None:
140
- """Send a resources/subscribe request."""
141
- if isinstance(uri, str):
142
- uri = AnyUrl(uri)
143
- await self.session.subscribe_resource(uri)
144
-
145
- async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
146
- """Send a resources/unsubscribe request."""
147
- if isinstance(uri, str):
148
- uri = AnyUrl(uri)
149
- await self.session.unsubscribe_resource(uri)
150
-
151
- async def list_prompts(self) -> mcp.types.ListPromptsResult:
149
+ result = await self.session.read_resource(uri)
150
+ return result.contents
151
+
152
+ # async def subscribe_resource(self, uri: AnyUrl | str) -> None:
153
+ # """Send a resources/subscribe request."""
154
+ # if isinstance(uri, str):
155
+ # uri = AnyUrl(uri)
156
+ # await self.session.subscribe_resource(uri)
157
+
158
+ # async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
159
+ # """Send a resources/unsubscribe request."""
160
+ # if isinstance(uri, str):
161
+ # uri = AnyUrl(uri)
162
+ # await self.session.unsubscribe_resource(uri)
163
+
164
+ async def list_prompts(self) -> list[mcp.types.Prompt]:
152
165
  """Send a prompts/list request."""
153
- return await self.session.list_prompts()
166
+ result = await self.session.list_prompts()
167
+ return result.prompts
154
168
 
155
169
  async def get_prompt(
156
170
  self, name: str, arguments: dict[str, str] | None = None
157
171
  ) -> mcp.types.GetPromptResult:
158
172
  """Send a prompts/get request."""
159
- return await self.session.get_prompt(name, arguments)
173
+ result = await self.session.get_prompt(name, arguments)
174
+ return result
160
175
 
161
176
  async def complete(
162
177
  self,
163
178
  ref: mcp.types.ResourceReference | mcp.types.PromptReference,
164
179
  argument: dict[str, str],
165
- ) -> mcp.types.CompleteResult:
166
- """Send a completion/complete request."""
167
- return await self.session.complete(ref, argument)
180
+ ) -> mcp.types.Completion:
181
+ """Send a completion request."""
182
+ result = await self.session.complete(ref, argument)
183
+ return result.completion
168
184
 
169
- async def list_tools(self) -> mcp.types.ListToolsResult:
185
+ async def list_tools(self) -> list[mcp.types.Tool]:
170
186
  """Send a tools/list request."""
171
- return await self.session.list_tools()
187
+ result = await self.session.list_tools()
188
+ return result.tools
172
189
 
190
+ @overload
173
191
  async def call_tool(
174
- self, name: str, arguments: dict[str, Any] | None = None
175
- ) -> mcp.types.CallToolResult:
176
- """Send a tools/call request."""
177
- return await self.session.call_tool(name, arguments)
192
+ self,
193
+ name: str,
194
+ arguments: dict[str, Any] | None = None,
195
+ _return_raw_result: Literal[False] = False,
196
+ ) -> list[
197
+ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
198
+ ]: ...
199
+
200
+ @overload
201
+ async def call_tool(
202
+ self,
203
+ name: str,
204
+ arguments: dict[str, Any] | None = None,
205
+ _return_raw_result: Literal[True] = True,
206
+ ) -> mcp.types.CallToolResult: ...
178
207
 
179
- async def send_roots_list_changed(self) -> None:
180
- """Send a roots/list_changed notification."""
181
- await self.session.send_roots_list_changed()
208
+ async def call_tool(
209
+ self,
210
+ name: str,
211
+ arguments: dict[str, Any] | None = None,
212
+ _return_raw_result: bool = False,
213
+ ) -> (
214
+ list[
215
+ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
216
+ ]
217
+ | mcp.types.CallToolResult
218
+ ):
219
+ """Send a tools/call request."""
220
+ result = await self.session.call_tool(name, arguments)
221
+ if _return_raw_result:
222
+ return result
223
+ elif result.isError:
224
+ msg = cast(mcp.types.TextContent, result.content[0]).text
225
+ raise ClientError(msg)
226
+ return result.content
@@ -1,4 +1,4 @@
1
- from .base import Prompt
1
+ from .prompt import Prompt
2
2
  from .prompt_manager import PromptManager
3
3
 
4
4
  __all__ = ["Prompt", "PromptManager"]
@@ -3,11 +3,14 @@
3
3
  import inspect
4
4
  import json
5
5
  from collections.abc import Awaitable, Callable, Sequence
6
- from typing import Any, Literal
6
+ from typing import Annotated, Any, Literal
7
7
 
8
8
  import pydantic_core
9
9
  from mcp.types import EmbeddedResource, ImageContent, TextContent
10
- from pydantic import BaseModel, Field, TypeAdapter, validate_call
10
+ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
+ from typing_extensions import Self
12
+
13
+ from fastmcp.utilities.types import _convert_set_defaults
11
14
 
12
15
  CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
13
16
 
@@ -71,10 +74,13 @@ class Prompt(BaseModel):
71
74
  description: str | None = Field(
72
75
  None, description="Description of what the prompt does"
73
76
  )
77
+ tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
78
+ default_factory=set, description="Tags for the prompt"
79
+ )
74
80
  arguments: list[PromptArgument] | None = Field(
75
81
  None, description="Arguments that can be passed to the prompt"
76
82
  )
77
- fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
83
+ fn: Callable[..., PromptResult | Awaitable[PromptResult]]
78
84
 
79
85
  @classmethod
80
86
  def from_function(
@@ -82,6 +88,7 @@ class Prompt(BaseModel):
82
88
  fn: Callable[..., PromptResult | Awaitable[PromptResult]],
83
89
  name: str | None = None,
84
90
  description: str | None = None,
91
+ tags: set[str] | None = None,
85
92
  ) -> "Prompt":
86
93
  """Create a Prompt from a function.
87
94
 
@@ -120,6 +127,7 @@ class Prompt(BaseModel):
120
127
  description=description or fn.__doc__ or "",
121
128
  arguments=arguments,
122
129
  fn=fn,
130
+ tags=tags or set(),
123
131
  )
124
132
 
125
133
  async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]:
@@ -164,3 +172,15 @@ class Prompt(BaseModel):
164
172
  return messages
165
173
  except Exception as e:
166
174
  raise ValueError(f"Error rendering prompt {self.name}: {e}")
175
+
176
+ def copy(self, updates: dict[str, Any] | None = None) -> Self:
177
+ """Copy the prompt with optional updates."""
178
+ data = self.model_dump()
179
+ if updates:
180
+ data.update(updates)
181
+ return type(self)(**data)
182
+
183
+ def __eq__(self, other: object) -> bool:
184
+ if not isinstance(other, Prompt):
185
+ return False
186
+ return self.model_dump() == other.model_dump()
@@ -1,8 +1,10 @@
1
1
  """Prompt management functionality."""
2
2
 
3
+ from collections.abc import Awaitable, Callable
3
4
  from typing import Any
4
5
 
5
- from fastmcp.prompts.base import Message, Prompt
6
+ from fastmcp.prompts.prompt import Message, Prompt, PromptResult
7
+ from fastmcp.settings import DuplicateBehavior
6
8
  from fastmcp.utilities.logging import get_logger
7
9
 
8
10
  logger = get_logger(__name__)
@@ -11,9 +13,9 @@ logger = get_logger(__name__)
11
13
  class PromptManager:
12
14
  """Manages FastMCP prompts."""
13
15
 
14
- def __init__(self, warn_on_duplicate_prompts: bool = True):
16
+ def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
15
17
  self._prompts: dict[str, Prompt] = {}
16
- self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
18
+ self.duplicate_behavior = duplicate_behavior
17
19
 
18
20
  def get_prompt(self, name: str) -> Prompt | None:
19
21
  """Get prompt by name."""
@@ -23,18 +25,32 @@ class PromptManager:
23
25
  """List all registered prompts."""
24
26
  return list(self._prompts.values())
25
27
 
26
- def add_prompt(
28
+ def add_prompt_from_fn(
27
29
  self,
28
- prompt: Prompt,
30
+ fn: Callable[..., PromptResult | Awaitable[PromptResult]],
31
+ name: str | None = None,
32
+ description: str | None = None,
33
+ tags: set[str] | None = None,
29
34
  ) -> Prompt:
35
+ """Create a prompt from a function."""
36
+ prompt = Prompt.from_function(fn, name=name, description=description, tags=tags)
37
+ return self.add_prompt(prompt)
38
+
39
+ def add_prompt(self, prompt: Prompt) -> Prompt:
30
40
  """Add a prompt to the manager."""
31
41
 
32
42
  # Check for duplicates
33
43
  existing = self._prompts.get(prompt.name)
34
44
  if existing:
35
- if self.warn_on_duplicate_prompts:
45
+ if self.duplicate_behavior == DuplicateBehavior.WARN:
36
46
  logger.warning(f"Prompt already exists: {prompt.name}")
37
- return existing
47
+ self._prompts[prompt.name] = prompt
48
+ elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
49
+ self._prompts[prompt.name] = prompt
50
+ elif self.duplicate_behavior == DuplicateBehavior.ERROR:
51
+ raise ValueError(f"Prompt already exists: {prompt.name}")
52
+ elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
53
+ pass
38
54
 
39
55
  self._prompts[prompt.name] = prompt
40
56
  return prompt
@@ -64,11 +80,11 @@ class PromptManager:
64
80
  the imported prompt would be available as "weather/forecast_prompt"
65
81
  """
66
82
  for name, prompt in manager._prompts.items():
67
- # Create prefixed name - we keep the original name in the Prompt object
83
+ # Create prefixed name
68
84
  prefixed_name = f"{prefix}{name}" if prefix else name
69
85
 
70
- # Log the import
71
- logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
86
+ new_prompt = prompt.copy(updates=dict(name=prefixed_name))
72
87
 
73
88
  # Store the prompt with the prefixed name
74
- self._prompts[prefixed_name] = prompt
89
+ self.add_prompt(new_prompt)
90
+ logger.debug(f'Imported prompt "{name}" as "{prefixed_name}"')
@@ -1,6 +1,6 @@
1
- from .base import Resource
1
+ from .resource import Resource
2
2
  from .resource_manager import ResourceManager
3
- from .templates import ResourceTemplate
3
+ from .template import ResourceTemplate
4
4
  from .types import (
5
5
  BinaryResource,
6
6
  DirectoryResource,
@@ -1,17 +1,21 @@
1
1
  """Base classes and interfaces for FastMCP resources."""
2
2
 
3
3
  import abc
4
- from typing import Annotated
4
+ from typing import Annotated, Any
5
5
 
6
6
  from pydantic import (
7
7
  AnyUrl,
8
8
  BaseModel,
9
+ BeforeValidator,
9
10
  ConfigDict,
10
11
  Field,
11
12
  UrlConstraints,
12
13
  ValidationInfo,
13
14
  field_validator,
14
15
  )
16
+ from typing_extensions import Self
17
+
18
+ from fastmcp.utilities.types import _convert_set_defaults
15
19
 
16
20
 
17
21
  class Resource(BaseModel, abc.ABC):
@@ -26,6 +30,9 @@ class Resource(BaseModel, abc.ABC):
26
30
  description: str | None = Field(
27
31
  description="Description of the resource", default=None
28
32
  )
33
+ tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
34
+ default_factory=set, description="Tags for the resource"
35
+ )
29
36
  mime_type: str = Field(
30
37
  default="text/plain",
31
38
  description="MIME type of the resource content",
@@ -46,3 +53,15 @@ class Resource(BaseModel, abc.ABC):
46
53
  async def read(self) -> str | bytes:
47
54
  """Read the resource content."""
48
55
  pass
56
+
57
+ def copy(self, updates: dict[str, Any] | None = None) -> Self:
58
+ """Copy the resource with optional updates."""
59
+ data = self.model_dump()
60
+ if updates:
61
+ data.update(updates)
62
+ return type(self)(**data)
63
+
64
+ def __eq__(self, other: object) -> bool:
65
+ if not isinstance(other, Resource):
66
+ return False
67
+ return self.model_dump() == other.model_dump()
@@ -5,8 +5,9 @@ from typing import Any
5
5
 
6
6
  from pydantic import AnyUrl
7
7
 
8
- from fastmcp.resources.base import Resource
9
- from fastmcp.resources.templates import ResourceTemplate
8
+ from fastmcp.resources.resource import Resource
9
+ from fastmcp.resources.template import ResourceTemplate
10
+ from fastmcp.settings import DuplicateBehavior
10
11
  from fastmcp.utilities.logging import get_logger
11
12
 
12
13
  logger = get_logger(__name__)
@@ -15,10 +16,10 @@ logger = get_logger(__name__)
15
16
  class ResourceManager:
16
17
  """Manages FastMCP resources."""
17
18
 
18
- def __init__(self, warn_on_duplicate_resources: bool = True):
19
+ def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
19
20
  self._resources: dict[str, Resource] = {}
20
21
  self._templates: dict[str, ResourceTemplate] = {}
21
- self.warn_on_duplicate_resources = warn_on_duplicate_resources
22
+ self.duplicate_behavior = duplicate_behavior
22
23
 
23
24
  def add_resource(self, resource: Resource) -> Resource:
24
25
  """Add a resource to the manager.
@@ -40,28 +41,67 @@ class ResourceManager:
40
41
  )
41
42
  existing = self._resources.get(str(resource.uri))
42
43
  if existing:
43
- if self.warn_on_duplicate_resources:
44
+ if self.duplicate_behavior == DuplicateBehavior.WARN:
44
45
  logger.warning(f"Resource already exists: {resource.uri}")
45
- return existing
46
+ self._resources[str(resource.uri)] = resource
47
+ elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
48
+ self._resources[str(resource.uri)] = resource
49
+ elif self.duplicate_behavior == DuplicateBehavior.ERROR:
50
+ raise ValueError(f"Resource already exists: {resource.uri}")
51
+ elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
52
+ pass
46
53
  self._resources[str(resource.uri)] = resource
47
54
  return resource
48
55
 
49
- def add_template(
56
+ def add_template_from_fn(
50
57
  self,
51
58
  fn: Callable[..., Any],
52
59
  uri_template: str,
53
60
  name: str | None = None,
54
61
  description: str | None = None,
55
62
  mime_type: str | None = None,
63
+ tags: set[str] | None = None,
56
64
  ) -> ResourceTemplate:
57
- """Add a template from a function."""
65
+ """Create a template from a function."""
58
66
  template = ResourceTemplate.from_function(
59
67
  fn,
60
68
  uri_template=uri_template,
61
69
  name=name,
62
70
  description=description,
63
71
  mime_type=mime_type,
72
+ tags=tags,
64
73
  )
74
+ return self.add_template(template)
75
+
76
+ def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
77
+ """Add a template to the manager.
78
+
79
+ Args:
80
+ template: A ResourceTemplate instance to add
81
+
82
+ Returns:
83
+ The added template. If a template with the same URI already exists,
84
+ returns the existing template.
85
+ """
86
+ logger.debug(
87
+ "Adding resource",
88
+ extra={
89
+ "uri": template.uri_template,
90
+ "type": type(template).__name__,
91
+ "resource_name": template.name,
92
+ },
93
+ )
94
+ existing = self._templates.get(str(template.uri_template))
95
+ if existing:
96
+ if self.duplicate_behavior == DuplicateBehavior.WARN:
97
+ logger.warning(f"Resource already exists: {template.uri_template}")
98
+ self._templates[str(template.uri_template)] = template
99
+ elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
100
+ self._templates[str(template.uri_template)] = template
101
+ elif self.duplicate_behavior == DuplicateBehavior.ERROR:
102
+ raise ValueError(f"Resource already exists: {template.uri_template}")
103
+ elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
104
+ pass
65
105
  self._templates[template.uri_template] = template
66
106
  return template
67
107
 
@@ -114,11 +154,11 @@ class ResourceManager:
114
154
  # Create prefixed URI and copy the resource with the new URI
115
155
  prefixed_uri = f"{prefix}{uri}" if prefix else uri
116
156
 
117
- # Log the import
118
- logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
157
+ new_resource = resource.copy(updates=dict(uri=prefixed_uri))
119
158
 
120
159
  # Store directly in resources dictionary
121
- self._resources[prefixed_uri] = resource
160
+ self.add_resource(new_resource)
161
+ logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
122
162
 
123
163
  def import_templates(
124
164
  self, manager: "ResourceManager", prefix: str | None = None
@@ -142,10 +182,12 @@ class ResourceManager:
142
182
  f"{prefix}{uri_template}" if prefix else uri_template
143
183
  )
144
184
 
145
- # Log the import
146
- logger.debug(
147
- f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
185
+ new_template = template.copy(
186
+ updates=dict(uri_template=prefixed_uri_template)
148
187
  )
149
188
 
150
189
  # Store directly in templates dictionary
151
- self._templates[prefixed_uri_template] = template
190
+ self.add_template(new_template)
191
+ logger.debug(
192
+ f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
193
+ )
@@ -5,11 +5,13 @@ from __future__ import annotations
5
5
  import inspect
6
6
  import re
7
7
  from collections.abc import Callable
8
- from typing import Any
8
+ from typing import Annotated, Any
9
9
 
10
- from pydantic import BaseModel, Field, TypeAdapter, validate_call
10
+ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
+ from typing_extensions import Self
11
12
 
12
13
  from fastmcp.resources.types import FunctionResource, Resource
14
+ from fastmcp.utilities.types import _convert_set_defaults
13
15
 
14
16
 
15
17
  class ResourceTemplate(BaseModel):
@@ -20,10 +22,13 @@ class ResourceTemplate(BaseModel):
20
22
  )
21
23
  name: str = Field(description="Name of the resource")
22
24
  description: str | None = Field(description="Description of what the resource does")
25
+ tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
26
+ default_factory=set, description="Tags for the resource"
27
+ )
23
28
  mime_type: str = Field(
24
29
  default="text/plain", description="MIME type of the resource content"
25
30
  )
26
- fn: Callable[..., Any] = Field(exclude=True)
31
+ fn: Callable[..., Any]
27
32
  parameters: dict[str, Any] = Field(
28
33
  description="JSON schema for function parameters"
29
34
  )
@@ -36,6 +41,7 @@ class ResourceTemplate(BaseModel):
36
41
  name: str | None = None,
37
42
  description: str | None = None,
38
43
  mime_type: str | None = None,
44
+ tags: set[str] | None = None,
39
45
  ) -> ResourceTemplate:
40
46
  """Create a template from a function."""
41
47
  func_name = name or fn.__name__
@@ -55,6 +61,7 @@ class ResourceTemplate(BaseModel):
55
61
  mime_type=mime_type or "text/plain",
56
62
  fn=fn,
57
63
  parameters=parameters,
64
+ tags=tags or set(),
58
65
  )
59
66
 
60
67
  def matches(self, uri: str) -> dict[str, Any] | None:
@@ -80,6 +87,19 @@ class ResourceTemplate(BaseModel):
80
87
  description=self.description,
81
88
  mime_type=self.mime_type,
82
89
  fn=lambda: result, # Capture result in closure
90
+ tags=self.tags,
83
91
  )
84
92
  except Exception as e:
85
93
  raise ValueError(f"Error creating resource from template: {e}")
94
+
95
+ def copy(self, updates: dict[str, Any] | None = None) -> Self:
96
+ """Copy the resource template with optional updates."""
97
+ data = self.model_dump()
98
+ if updates:
99
+ data.update(updates)
100
+ return type(self)(**data)
101
+
102
+ def __eq__(self, other: object) -> bool:
103
+ if not isinstance(other, ResourceTemplate):
104
+ return False
105
+ return self.model_dump() == other.model_dump()
@@ -13,7 +13,7 @@ import pydantic.json
13
13
  import pydantic_core
14
14
  from pydantic import Field, ValidationInfo
15
15
 
16
- from fastmcp.resources.base import Resource
16
+ from fastmcp.resources.resource import Resource
17
17
 
18
18
 
19
19
  class TextResource(Resource):
@@ -49,7 +49,7 @@ class FunctionResource(Resource):
49
49
  - other types will be converted to JSON
50
50
  """
51
51
 
52
- fn: Callable[[], Any] = Field(exclude=True)
52
+ fn: Callable[[], Any]
53
53
 
54
54
  async def read(self) -> str | bytes:
55
55
  """Read the resource by calling the wrapped function."""