Jeremiah Lowin commited on
Commit
fa364d7
·
1 Parent(s): 316c336

Add tags, improve duplicate import behavior

Browse files
.cursor/rules/core-mcp-objects.mdc ADDED
@@ -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`.
src/fastmcp/prompts/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- from .base import Prompt
2
  from .prompt_manager import PromptManager
3
 
4
  __all__ = ["Prompt", "PromptManager"]
 
1
+ from .prompt import Prompt
2
  from .prompt_manager import PromptManager
3
 
4
  __all__ = ["Prompt", "PromptManager"]
src/fastmcp/prompts/{base.py → prompt.py} RENAMED
@@ -8,6 +8,7 @@ from typing import Annotated, Any, Literal
8
  import pydantic_core
9
  from mcp.types import EmbeddedResource, ImageContent, TextContent
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
 
11
 
12
  from fastmcp.utilities.types import _convert_set_defaults
13
 
@@ -79,7 +80,7 @@ class Prompt(BaseModel):
79
  arguments: list[PromptArgument] | None = Field(
80
  None, description="Arguments that can be passed to the prompt"
81
  )
82
- fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
83
 
84
  @classmethod
85
  def from_function(
@@ -171,3 +172,15 @@ class Prompt(BaseModel):
171
  return messages
172
  except Exception as e:
173
  raise ValueError(f"Error rendering prompt {self.name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import pydantic_core
9
  from mcp.types import EmbeddedResource, ImageContent, TextContent
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
14
 
 
80
  arguments: list[PromptArgument] | None = Field(
81
  None, description="Arguments that can be passed to the prompt"
82
  )
83
+ fn: Callable[..., PromptResult | Awaitable[PromptResult]]
84
 
85
  @classmethod
86
  def from_function(
 
172
  return messages
173
  except Exception as e:
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()
src/fastmcp/prompts/prompt_manager.py CHANGED
@@ -1,8 +1,10 @@
1
  """Prompt management functionality."""
2
 
 
3
  from typing import Any
4
 
5
- from fastmcp.prompts.base import Message, Prompt
 
6
  from fastmcp.utilities.logging import get_logger
7
 
8
  logger = get_logger(__name__)
@@ -11,9 +13,9 @@ logger = get_logger(__name__)
11
  class PromptManager:
12
  """Manages FastMCP prompts."""
13
 
14
- def __init__(self, warn_on_duplicate_prompts: bool = True):
15
  self._prompts: dict[str, Prompt] = {}
16
- self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
17
 
18
  def get_prompt(self, name: str) -> Prompt | None:
19
  """Get prompt by name."""
@@ -23,18 +25,32 @@ class PromptManager:
23
  """List all registered prompts."""
24
  return list(self._prompts.values())
25
 
26
- def add_prompt(
27
  self,
28
- prompt: Prompt,
 
 
 
29
  ) -> Prompt:
 
 
 
 
 
30
  """Add a prompt to the manager."""
31
 
32
  # Check for duplicates
33
  existing = self._prompts.get(prompt.name)
34
  if existing:
35
- if self.warn_on_duplicate_prompts:
36
  logger.warning(f"Prompt already exists: {prompt.name}")
37
- return existing
 
 
 
 
 
 
38
 
39
  self._prompts[prompt.name] = prompt
40
  return prompt
@@ -64,11 +80,13 @@ class PromptManager:
64
  the imported prompt would be available as "weather/forecast_prompt"
65
  """
66
  for name, prompt in manager._prompts.items():
67
- # Create prefixed name - we keep the original name in the Prompt object
68
  prefixed_name = f"{prefix}{name}" if prefix else name
69
 
 
 
70
  # Log the import
71
  logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
72
 
73
  # Store the prompt with the prefixed name
74
- self._prompts[prefixed_name] = prompt
 
1
  """Prompt management functionality."""
2
 
3
+ from collections.abc import Awaitable, Callable
4
  from typing import Any
5
 
6
+ from fastmcp.prompts.prompt import Message, Prompt, PromptResult
7
+ from fastmcp.settings import DuplicateBehavior
8
  from fastmcp.utilities.logging import get_logger
9
 
10
  logger = get_logger(__name__)
 
13
  class PromptManager:
14
  """Manages FastMCP prompts."""
15
 
16
+ def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
17
  self._prompts: dict[str, Prompt] = {}
18
+ self.duplicate_behavior = duplicate_behavior
19
 
20
  def get_prompt(self, name: str) -> Prompt | None:
21
  """Get prompt by name."""
 
25
  """List all registered prompts."""
26
  return list(self._prompts.values())
27
 
28
+ def add_prompt_from_fn(
29
  self,
30
+ fn: Callable[..., PromptResult | Awaitable[PromptResult]],
31
+ name: str | None = None,
32
+ description: str | None = None,
33
+ tags: set[str] | None = None,
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:
40
  """Add a prompt to the manager."""
41
 
42
  # Check for duplicates
43
  existing = self._prompts.get(prompt.name)
44
  if existing:
45
+ if self.duplicate_behavior == DuplicateBehavior.WARN:
46
  logger.warning(f"Prompt already exists: {prompt.name}")
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
54
 
55
  self._prompts[prompt.name] = prompt
56
  return prompt
 
80
  the imported prompt would be available as "weather/forecast_prompt"
81
  """
82
  for name, prompt in manager._prompts.items():
83
+ # Create prefixed name
84
  prefixed_name = f"{prefix}{name}" if prefix else name
85
 
86
+ new_prompt = prompt.copy(updates=dict(name=prefixed_name))
87
+
88
  # Log the import
89
  logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
90
 
91
  # Store the prompt with the prefixed name
92
+ self.add_prompt(new_prompt)
src/fastmcp/resources/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
- from .base import Resource
2
  from .resource_manager import ResourceManager
3
- from .templates import ResourceTemplate
4
  from .types import (
5
  BinaryResource,
6
  DirectoryResource,
 
1
+ from .resource import Resource
2
  from .resource_manager import ResourceManager
3
+ from .template import ResourceTemplate
4
  from .types import (
5
  BinaryResource,
6
  DirectoryResource,
src/fastmcp/resources/{base.py → resource.py} RENAMED
@@ -1,7 +1,7 @@
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
- from typing import Annotated
5
 
6
  from pydantic import (
7
  AnyUrl,
@@ -13,6 +13,7 @@ from pydantic import (
13
  ValidationInfo,
14
  field_validator,
15
  )
 
16
 
17
  from fastmcp.utilities.types import _convert_set_defaults
18
 
@@ -52,3 +53,15 @@ class Resource(BaseModel, abc.ABC):
52
  async def read(self) -> str | bytes:
53
  """Read the resource content."""
54
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Base classes and interfaces for FastMCP resources."""
2
 
3
  import abc
4
+ from typing import Annotated, Any
5
 
6
  from pydantic import (
7
  AnyUrl,
 
13
  ValidationInfo,
14
  field_validator,
15
  )
16
+ from typing_extensions import Self
17
 
18
  from fastmcp.utilities.types import _convert_set_defaults
19
 
 
53
  async def read(self) -> str | bytes:
54
  """Read the resource content."""
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()
src/fastmcp/resources/resource_manager.py CHANGED
@@ -5,8 +5,9 @@ from typing import Any
5
 
6
  from pydantic import AnyUrl
7
 
8
- from fastmcp.resources.base import Resource
9
- from fastmcp.resources.templates import ResourceTemplate
 
10
  from fastmcp.utilities.logging import get_logger
11
 
12
  logger = get_logger(__name__)
@@ -15,10 +16,10 @@ logger = get_logger(__name__)
15
  class ResourceManager:
16
  """Manages FastMCP resources."""
17
 
18
- def __init__(self, warn_on_duplicate_resources: bool = True):
19
  self._resources: dict[str, Resource] = {}
20
  self._templates: dict[str, ResourceTemplate] = {}
21
- self.warn_on_duplicate_resources = warn_on_duplicate_resources
22
 
23
  def add_resource(self, resource: Resource) -> Resource:
24
  """Add a resource to the manager.
@@ -40,13 +41,19 @@ class ResourceManager:
40
  )
41
  existing = self._resources.get(str(resource.uri))
42
  if existing:
43
- if self.warn_on_duplicate_resources:
44
  logger.warning(f"Resource already exists: {resource.uri}")
45
- return existing
 
 
 
 
 
 
46
  self._resources[str(resource.uri)] = resource
47
  return resource
48
 
49
- def add_template(
50
  self,
51
  fn: Callable[..., Any],
52
  uri_template: str,
@@ -55,7 +62,7 @@ class ResourceManager:
55
  mime_type: str | None = None,
56
  tags: set[str] | None = None,
57
  ) -> ResourceTemplate:
58
- """Add a template from a function."""
59
  template = ResourceTemplate.from_function(
60
  fn,
61
  uri_template=uri_template,
@@ -64,6 +71,37 @@ class ResourceManager:
64
  mime_type=mime_type,
65
  tags=tags,
66
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  self._templates[template.uri_template] = template
68
  return template
69
 
@@ -116,11 +154,13 @@ class ResourceManager:
116
  # Create prefixed URI and copy the resource with the new URI
117
  prefixed_uri = f"{prefix}{uri}" if prefix else uri
118
 
 
 
119
  # Log the import
120
  logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
121
 
122
  # Store directly in resources dictionary
123
- self._resources[prefixed_uri] = resource
124
 
125
  def import_templates(
126
  self, manager: "ResourceManager", prefix: str | None = None
@@ -144,10 +184,14 @@ class ResourceManager:
144
  f"{prefix}{uri_template}" if prefix else uri_template
145
  )
146
 
 
 
 
 
147
  # Log the import
148
  logger.debug(
149
  f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
150
  )
151
 
152
  # Store directly in templates dictionary
153
- self._templates[prefixed_uri_template] = template
 
5
 
6
  from pydantic import AnyUrl
7
 
8
+ from fastmcp.resources.resource import Resource
9
+ from fastmcp.resources.template import ResourceTemplate
10
+ from fastmcp.settings import DuplicateBehavior
11
  from fastmcp.utilities.logging import get_logger
12
 
13
  logger = get_logger(__name__)
 
16
  class ResourceManager:
17
  """Manages FastMCP resources."""
18
 
19
+ def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
20
  self._resources: dict[str, Resource] = {}
21
  self._templates: dict[str, ResourceTemplate] = {}
22
+ self.duplicate_behavior = duplicate_behavior
23
 
24
  def add_resource(self, resource: Resource) -> Resource:
25
  """Add a resource to the manager.
 
41
  )
42
  existing = self._resources.get(str(resource.uri))
43
  if existing:
44
+ if self.duplicate_behavior == DuplicateBehavior.WARN:
45
  logger.warning(f"Resource already exists: {resource.uri}")
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
53
  self._resources[str(resource.uri)] = resource
54
  return resource
55
 
56
+ def add_template_from_fn(
57
  self,
58
  fn: Callable[..., Any],
59
  uri_template: str,
 
62
  mime_type: str | None = None,
63
  tags: set[str] | None = None,
64
  ) -> ResourceTemplate:
65
+ """Create a template from a function."""
66
  template = ResourceTemplate.from_function(
67
  fn,
68
  uri_template=uri_template,
 
71
  mime_type=mime_type,
72
  tags=tags,
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
105
  self._templates[template.uri_template] = template
106
  return template
107
 
 
154
  # Create prefixed URI and copy the resource with the new URI
155
  prefixed_uri = f"{prefix}{uri}" if prefix else uri
156
 
157
+ new_resource = resource.copy(updates=dict(uri=prefixed_uri))
158
+
159
  # Log the import
160
  logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
161
 
162
  # Store directly in resources dictionary
163
+ self.add_resource(new_resource)
164
 
165
  def import_templates(
166
  self, manager: "ResourceManager", prefix: str | None = None
 
184
  f"{prefix}{uri_template}" if prefix else uri_template
185
  )
186
 
187
+ new_template = template.copy(
188
+ updates=dict(uri_template=prefixed_uri_template)
189
+ )
190
+
191
  # Log the import
192
  logger.debug(
193
  f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
194
  )
195
 
196
  # Store directly in templates dictionary
197
+ self.add_template(new_template)
src/fastmcp/resources/{templates.py → template.py} RENAMED
@@ -8,6 +8,7 @@ from collections.abc import Callable
8
  from typing import Annotated, Any
9
 
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
 
11
 
12
  from fastmcp.resources.types import FunctionResource, Resource
13
  from fastmcp.utilities.types import _convert_set_defaults
@@ -27,7 +28,7 @@ class ResourceTemplate(BaseModel):
27
  mime_type: str = Field(
28
  default="text/plain", description="MIME type of the resource content"
29
  )
30
- fn: Callable[..., Any] = Field(exclude=True)
31
  parameters: dict[str, Any] = Field(
32
  description="JSON schema for function parameters"
33
  )
@@ -90,3 +91,15 @@ class ResourceTemplate(BaseModel):
90
  )
91
  except Exception as e:
92
  raise ValueError(f"Error creating resource from template: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from typing import Annotated, Any
9
 
10
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
11
+ from typing_extensions import Self
12
 
13
  from fastmcp.resources.types import FunctionResource, Resource
14
  from fastmcp.utilities.types import _convert_set_defaults
 
28
  mime_type: str = Field(
29
  default="text/plain", description="MIME type of the resource content"
30
  )
31
+ fn: Callable[..., Any]
32
  parameters: dict[str, Any] = Field(
33
  description="JSON schema for function parameters"
34
  )
 
91
  )
92
  except Exception as e:
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()
src/fastmcp/resources/types.py CHANGED
@@ -13,7 +13,7 @@ import pydantic.json
13
  import pydantic_core
14
  from pydantic import Field, ValidationInfo
15
 
16
- from fastmcp.resources.base import Resource
17
 
18
 
19
  class TextResource(Resource):
@@ -49,7 +49,7 @@ class FunctionResource(Resource):
49
  - other types will be converted to JSON
50
  """
51
 
52
- fn: Callable[[], Any] = Field(exclude=True)
53
 
54
  async def read(self) -> str | bytes:
55
  """Read the resource by calling the wrapped function."""
 
13
  import pydantic_core
14
  from pydantic import Field, ValidationInfo
15
 
16
+ from fastmcp.resources.resource import Resource
17
 
18
 
19
  class TextResource(Resource):
 
49
  - other types will be converted to JSON
50
  """
51
 
52
+ fn: Callable[[], Any]
53
 
54
  async def read(self) -> str | bytes:
55
  """Read the resource by calling the wrapped function."""
src/fastmcp/server/openapi.py CHANGED
@@ -12,7 +12,7 @@ from pydantic.networks import AnyUrl
12
 
13
  from fastmcp.resources import Resource, ResourceTemplate
14
  from fastmcp.server.server import FastMCP
15
- from fastmcp.tools.base import Tool
16
  from fastmcp.utilities import openapi
17
  from fastmcp.utilities.func_metadata import func_metadata
18
  from fastmcp.utilities.logging import get_logger
 
12
 
13
  from fastmcp.resources import Resource, ResourceTemplate
14
  from fastmcp.server.server import FastMCP
15
+ from fastmcp.tools.tool import Tool
16
  from fastmcp.utilities import openapi
17
  from fastmcp.utilities.func_metadata import func_metadata
18
  from fastmcp.utilities.logging import get_logger
src/fastmcp/server/proxy.py CHANGED
@@ -9,7 +9,7 @@ from fastmcp.prompts import Prompt
9
  from fastmcp.resources import Resource, ResourceTemplate
10
  from fastmcp.server.context import Context
11
  from fastmcp.server.server import FastMCP
12
- from fastmcp.tools.base import Tool
13
  from fastmcp.utilities.func_metadata import func_metadata
14
  from fastmcp.utilities.logging import get_logger
15
 
 
9
  from fastmcp.resources import Resource, ResourceTemplate
10
  from fastmcp.server.context import Context
11
  from fastmcp.server.server import FastMCP
12
+ from fastmcp.tools.tool import Tool
13
  from fastmcp.utilities.func_metadata import func_metadata
14
  from fastmcp.utilities.logging import get_logger
15
 
src/fastmcp/server/server.py CHANGED
@@ -88,13 +88,13 @@ class FastMCP(Generic[LifespanResultT]):
88
  lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
89
  )
90
  self._tool_manager = ToolManager(
91
- warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
92
  )
93
  self._resource_manager = ResourceManager(
94
- warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
95
  )
96
  self._prompt_manager = PromptManager(
97
- warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
98
  )
99
  self.dependencies = self.settings.dependencies
100
 
@@ -241,7 +241,9 @@ class FastMCP(Generic[LifespanResultT]):
241
  description: Optional description of what the tool does
242
  tags: Optional set of tags for categorizing the tool
243
  """
244
- self._tool_manager.add_tool(fn, name=name, description=description, tags=tags)
 
 
245
 
246
  def tool(
247
  self,
@@ -366,7 +368,7 @@ class FastMCP(Generic[LifespanResultT]):
366
  )
367
 
368
  # Register as template
369
- self._resource_manager.add_template(
370
  fn=fn,
371
  uri_template=uri,
372
  name=name,
 
88
  lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
89
  )
90
  self._tool_manager = ToolManager(
91
+ duplicate_behavior=self.settings.on_duplicate_tools
92
  )
93
  self._resource_manager = ResourceManager(
94
+ duplicate_behavior=self.settings.on_duplicate_resources
95
  )
96
  self._prompt_manager = PromptManager(
97
+ duplicate_behavior=self.settings.on_duplicate_prompts
98
  )
99
  self.dependencies = self.settings.dependencies
100
 
 
241
  description: Optional description of what the tool does
242
  tags: Optional set of tags for categorizing the tool
243
  """
244
+ self._tool_manager.add_tool_from_fn(
245
+ fn, name=name, description=description, tags=tags
246
+ )
247
 
248
  def tool(
249
  self,
 
368
  )
369
 
370
  # Register as template
371
+ self._resource_manager.add_template_from_fn(
372
  fn=fn,
373
  uri_template=uri,
374
  name=name,
src/fastmcp/settings.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations as _annotations
2
 
 
3
  from typing import TYPE_CHECKING, Literal
4
 
5
  from pydantic import Field
@@ -11,6 +12,13 @@ if TYPE_CHECKING:
11
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
12
 
13
 
 
 
 
 
 
 
 
14
  class Settings(BaseSettings):
15
  """FastMCP settings."""
16
 
@@ -47,13 +55,13 @@ class ServerSettings(BaseSettings):
47
  debug: bool = False
48
 
49
  # resource settings
50
- warn_on_duplicate_resources: bool = True
51
 
52
  # tool settings
53
- warn_on_duplicate_tools: bool = True
54
 
55
  # prompt settings
56
- warn_on_duplicate_prompts: bool = True
57
 
58
  dependencies: list[str] = Field(
59
  default_factory=list,
 
1
  from __future__ import annotations as _annotations
2
 
3
+ from enum import Enum
4
  from typing import TYPE_CHECKING, Literal
5
 
6
  from pydantic import Field
 
12
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
13
 
14
 
15
+ class DuplicateBehavior(Enum):
16
+ WARN = "warn"
17
+ ERROR = "error"
18
+ REPLACE = "replace"
19
+ IGNORE = "ignore"
20
+
21
+
22
  class Settings(BaseSettings):
23
  """FastMCP settings."""
24
 
 
55
  debug: bool = False
56
 
57
  # resource settings
58
+ on_duplicate_resources: DuplicateBehavior = DuplicateBehavior.WARN
59
 
60
  # tool settings
61
+ on_duplicate_tools: DuplicateBehavior = DuplicateBehavior.WARN
62
 
63
  # prompt settings
64
+ on_duplicate_prompts: DuplicateBehavior = DuplicateBehavior.WARN
65
 
66
  dependencies: list[str] = Field(
67
  default_factory=list,
src/fastmcp/tools/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- from .base import Tool
2
  from .tool_manager import ToolManager
3
 
4
  __all__ = ["Tool", "ToolManager"]
 
1
+ from .tool import Tool
2
  from .tool_manager import ToolManager
3
 
4
  __all__ = ["Tool", "ToolManager"]
src/fastmcp/tools/{base.py → tool.py} RENAMED
@@ -5,6 +5,7 @@ from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
  from pydantic import BaseModel, BeforeValidator, Field
 
8
 
9
  from fastmcp.exceptions import ToolError
10
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
@@ -20,7 +21,7 @@ if TYPE_CHECKING:
20
  class Tool(BaseModel):
21
  """Internal tool registration info."""
22
 
23
- fn: Callable[..., Any] = Field(exclude=True)
24
  name: str = Field(description="Name of the tool")
25
  description: str = Field(description="Description of what the tool does")
26
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
@@ -97,3 +98,15 @@ class Tool(BaseModel):
97
  )
98
  except Exception as e:
99
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
  from pydantic import BaseModel, BeforeValidator, Field
8
+ from typing_extensions import Self
9
 
10
  from fastmcp.exceptions import ToolError
11
  from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
 
21
  class Tool(BaseModel):
22
  """Internal tool registration info."""
23
 
24
+ fn: Callable[..., Any]
25
  name: str = Field(description="Name of the tool")
26
  description: str = Field(description="Description of what the tool does")
27
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
 
98
  )
99
  except Exception as e:
100
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
101
+
102
+ def copy(self, updates: dict[str, Any] | None = None) -> Self:
103
+ """Copy the tool with optional updates."""
104
+ data = self.model_dump()
105
+ if updates:
106
+ data.update(updates)
107
+ return type(self)(**data)
108
+
109
+ def __eq__(self, other: object) -> bool:
110
+ if not isinstance(other, Tool):
111
+ return False
112
+ return self.model_dump() == other.model_dump()
src/fastmcp/tools/tool_manager.py CHANGED
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, Any
6
  from mcp.shared.context import LifespanContextT
7
 
8
  from fastmcp.exceptions import ToolError
9
- from fastmcp.tools.base import Tool
 
10
  from fastmcp.utilities.logging import get_logger
11
 
12
  if TYPE_CHECKING:
@@ -20,9 +21,9 @@ logger = get_logger(__name__)
20
  class ToolManager:
21
  """Manages FastMCP tools."""
22
 
23
- def __init__(self, warn_on_duplicate_tools: bool = True):
24
  self._tools: dict[str, Tool] = {}
25
- self.warn_on_duplicate_tools = warn_on_duplicate_tools
26
 
27
  def get_tool(self, name: str) -> Tool | None:
28
  """Get tool by name."""
@@ -32,7 +33,7 @@ class ToolManager:
32
  """List all registered tools."""
33
  return list(self._tools.values())
34
 
35
- def add_tool(
36
  self,
37
  fn: Callable[..., Any],
38
  name: str | None = None,
@@ -41,15 +42,21 @@ class ToolManager:
41
  ) -> Tool:
42
  """Add a tool to the server."""
43
  tool = Tool.from_function(fn, name=name, description=description, tags=tags)
44
- return self._register_tool(tool)
45
 
46
- def _register_tool(self, tool: Tool) -> Tool:
47
  """Register a tool with the server."""
48
  existing = self._tools.get(tool.name)
49
  if existing:
50
- if self.warn_on_duplicate_tools:
51
  logger.warning(f"Tool already exists: {tool.name}")
52
- return existing
 
 
 
 
 
 
53
  self._tools[tool.name] = tool
54
  return tool
55
 
@@ -83,13 +90,7 @@ class ToolManager:
83
  for name, tool in tool_manager._tools.items():
84
  prefixed_name = f"{prefix}{name}" if prefix else name
85
 
86
- # Create a shallow copy of the tool with the prefixed name
87
- copied_tool = Tool.from_function(
88
- tool.fn,
89
- name=prefixed_name,
90
- description=tool.description,
91
- )
92
-
93
  # Store the copied tool
94
- self._register_tool(copied_tool)
95
  logger.debug(f"Imported tool: {name} as {prefixed_name}")
 
6
  from mcp.shared.context import LifespanContextT
7
 
8
  from fastmcp.exceptions import ToolError
9
+ from fastmcp.settings import DuplicateBehavior
10
+ from fastmcp.tools.tool import Tool
11
  from fastmcp.utilities.logging import get_logger
12
 
13
  if TYPE_CHECKING:
 
21
  class ToolManager:
22
  """Manages FastMCP tools."""
23
 
24
+ def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
25
  self._tools: dict[str, Tool] = {}
26
+ self.duplicate_behavior = duplicate_behavior
27
 
28
  def get_tool(self, name: str) -> Tool | None:
29
  """Get tool by name."""
 
33
  """List all registered tools."""
34
  return list(self._tools.values())
35
 
36
+ def add_tool_from_fn(
37
  self,
38
  fn: Callable[..., Any],
39
  name: str | None = None,
 
42
  ) -> Tool:
43
  """Add a tool to the server."""
44
  tool = Tool.from_function(fn, name=name, description=description, tags=tags)
45
+ return self.add_tool(tool)
46
 
47
+ def add_tool(self, tool: Tool) -> Tool:
48
  """Register a tool with the server."""
49
  existing = self._tools.get(tool.name)
50
  if existing:
51
+ if self.duplicate_behavior == DuplicateBehavior.WARN:
52
  logger.warning(f"Tool already exists: {tool.name}")
53
+ self._tools[tool.name] = tool
54
+ elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
55
+ self._tools[tool.name] = tool
56
+ elif self.duplicate_behavior == DuplicateBehavior.ERROR:
57
+ raise ValueError(f"Tool already exists: {tool.name}")
58
+ elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
59
+ pass
60
  self._tools[tool.name] = tool
61
  return tool
62
 
 
90
  for name, tool in tool_manager._tools.items():
91
  prefixed_name = f"{prefix}{name}" if prefix else name
92
 
93
+ new_tool = tool.copy(updates=dict(name=prefixed_name))
 
 
 
 
 
 
94
  # Store the copied tool
95
+ self.add_tool(new_tool)
96
  logger.debug(f"Imported tool: {name} as {prefixed_name}")
tests/prompts/test_base.py CHANGED
@@ -2,7 +2,7 @@ import pytest
2
  from mcp.types import EmbeddedResource, TextResourceContents
3
  from pydantic import FileUrl
4
 
5
- from fastmcp.prompts.base import (
6
  AssistantMessage,
7
  Message,
8
  Prompt,
 
2
  from mcp.types import EmbeddedResource, TextResourceContents
3
  from pydantic import FileUrl
4
 
5
+ from fastmcp.prompts.prompt import (
6
  AssistantMessage,
7
  Message,
8
  Prompt,
tests/prompts/test_prompt_manager.py CHANGED
@@ -1,8 +1,9 @@
1
  import pytest
2
 
3
  from fastmcp.prompts import Prompt
4
- from fastmcp.prompts.base import PromptArgument, TextContent, UserMessage
5
  from fastmcp.prompts.prompt_manager import PromptManager
 
6
 
7
 
8
  class TestPromptManager:
@@ -24,7 +25,7 @@ class TestPromptManager:
24
  def fn() -> str:
25
  return "Hello, world!"
26
 
27
- manager = PromptManager()
28
  prompt = Prompt.from_function(fn)
29
  first = manager.add_prompt(prompt)
30
  second = manager.add_prompt(prompt)
@@ -37,7 +38,7 @@ class TestPromptManager:
37
  def fn() -> str:
38
  return "Hello, world!"
39
 
40
- manager = PromptManager(warn_on_duplicate_prompts=False)
41
  prompt = Prompt.from_function(fn)
42
  first = manager.add_prompt(prompt)
43
  second = manager.add_prompt(prompt)
@@ -112,6 +113,131 @@ class TestPromptManager:
112
  with pytest.raises(ValueError, match="Missing required arguments"):
113
  await manager.render_prompt("fn")
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  class TestImports:
117
  def test_import_prompts(self):
@@ -119,27 +245,13 @@ class TestImports:
119
  # Setup source manager with prompts
120
  source_manager = PromptManager()
121
 
122
- # Create test prompts with proper function handlers
123
- async def summary_fn(**kwargs):
124
- return [
125
- {"role": "assistant", "content": f"Summary of: {kwargs.get('text')}"}
126
- ]
127
-
128
- async def translate_fn(**kwargs):
129
- return [
130
- {
131
- "role": "assistant",
132
- "content": f"Translation to {kwargs.get('language')}: {kwargs.get('text')}",
133
- }
134
- ]
135
-
136
  summary_prompt = Prompt(
137
  name="summary",
138
  description="Generate a summary of text",
139
  arguments=[PromptArgument(name="text", description="Text to summarize")],
140
- fn=summary_fn,
141
  )
142
- source_manager._prompts["summary"] = summary_prompt
143
 
144
  translate_prompt = Prompt(
145
  name="translate",
@@ -148,9 +260,9 @@ class TestImports:
148
  PromptArgument(name="text", description="Text to translate"),
149
  PromptArgument(name="language", description="Target language"),
150
  ],
151
- fn=translate_fn,
152
  )
153
- source_manager._prompts["translate"] = translate_prompt
154
 
155
  # Create target manager
156
  target_manager = PromptManager()
@@ -167,31 +279,8 @@ class TestImports:
167
  assert "summary" in source_manager._prompts
168
  assert "translate" in source_manager._prompts
169
 
170
- # Verify the imported prompts have the correct properties
171
- assert target_manager._prompts["nlp/summary"].name == "summary"
172
- assert (
173
- target_manager._prompts["nlp/summary"].description
174
- == "Generate a summary of text"
175
- )
176
-
177
- assert target_manager._prompts["nlp/translate"].name == "translate"
178
- assert (
179
- target_manager._prompts["nlp/translate"].description
180
- == "Translate text to another language"
181
- )
182
-
183
- # Verify functions were properly copied
184
- if hasattr(target_manager._prompts["nlp/summary"], "fn"):
185
- assert (
186
- target_manager._prompts["nlp/summary"].fn.__name__
187
- == summary_fn.__name__
188
- )
189
-
190
- if hasattr(target_manager._prompts["nlp/translate"], "fn"):
191
- assert (
192
- target_manager._prompts["nlp/translate"].fn.__name__
193
- == translate_fn.__name__
194
- )
195
 
196
  def test_import_prompts_with_duplicates(self):
197
  """Test handling of duplicate prompts during import."""
@@ -199,18 +288,11 @@ class TestImports:
199
  source_manager = PromptManager()
200
  target_manager = PromptManager()
201
 
202
- # Add the same prompt name to both managers with functions
203
- async def source_fn(**kwargs):
204
- return [{"role": "assistant", "content": "Source content"}]
205
-
206
- async def target_fn(**kwargs):
207
- return [{"role": "assistant", "content": "Target content"}]
208
-
209
  source_prompt = Prompt(
210
  name="common",
211
  description="Source description",
212
  arguments=None,
213
- fn=source_fn,
214
  )
215
  source_manager._prompts["common"] = source_prompt
216
 
@@ -218,7 +300,7 @@ class TestImports:
218
  name="common",
219
  description="Target description",
220
  arguments=None,
221
- fn=target_fn,
222
  )
223
  target_manager._prompts["common"] = target_prompt
224
 
@@ -230,15 +312,8 @@ class TestImports:
230
  assert "common" in target_manager._prompts
231
  assert "external/common" in target_manager._prompts
232
 
233
- # Verify the functions of both prompts
234
- if hasattr(target_manager._prompts["common"], "fn") and hasattr(
235
- target_manager._prompts["external/common"], "fn"
236
- ):
237
- assert target_manager._prompts["common"].fn.__name__ == target_fn.__name__
238
- assert (
239
- target_manager._prompts["external/common"].fn.__name__
240
- == source_fn.__name__
241
- )
242
 
243
  def test_import_prompts_with_nested_prefixes(self):
244
  """Test importing already prefixed prompts."""
@@ -247,17 +322,11 @@ class TestImports:
247
  second_manager = PromptManager()
248
  third_manager = PromptManager()
249
 
250
- # Add prompt to first manager with a function
251
- async def analyze_fn(**kwargs):
252
- return [
253
- {"role": "assistant", "content": f"Analysis of: {kwargs.get('text')}"}
254
- ]
255
-
256
  original_prompt = Prompt(
257
  name="analyze",
258
  description="Analyze text",
259
  arguments=[PromptArgument(name="text", description="Text to analyze")],
260
- fn=analyze_fn,
261
  )
262
  first_manager._prompts["analyze"] = original_prompt
263
 
@@ -271,13 +340,4 @@ class TestImports:
271
  assert "text/analyze" in second_manager._prompts
272
  assert "ai/text/analyze" in third_manager._prompts
273
 
274
- # Verify the properties of the most nested prompt
275
- assert third_manager._prompts["ai/text/analyze"].name == "analyze"
276
- assert third_manager._prompts["ai/text/analyze"].description == "Analyze text"
277
-
278
- # Verify function was properly copied through multiple imports
279
- if hasattr(third_manager._prompts["ai/text/analyze"], "fn"):
280
- assert (
281
- third_manager._prompts["ai/text/analyze"].fn.__name__
282
- == analyze_fn.__name__
283
- )
 
1
  import pytest
2
 
3
  from fastmcp.prompts import Prompt
4
+ from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
5
  from fastmcp.prompts.prompt_manager import PromptManager
6
+ from fastmcp.settings import DuplicateBehavior
7
 
8
 
9
  class TestPromptManager:
 
25
  def fn() -> str:
26
  return "Hello, world!"
27
 
28
+ manager = PromptManager(duplicate_behavior=DuplicateBehavior.WARN)
29
  prompt = Prompt.from_function(fn)
30
  first = manager.add_prompt(prompt)
31
  second = manager.add_prompt(prompt)
 
38
  def fn() -> str:
39
  return "Hello, world!"
40
 
41
+ manager = PromptManager(duplicate_behavior=DuplicateBehavior.IGNORE)
42
  prompt = Prompt.from_function(fn)
43
  first = manager.add_prompt(prompt)
44
  second = manager.add_prompt(prompt)
 
113
  with pytest.raises(ValueError, match="Missing required arguments"):
114
  await manager.render_prompt("fn")
115
 
116
+ def test_error_on_duplicate_prompts(self):
117
+ """Test error on duplicate prompts."""
118
+
119
+ def fn() -> str:
120
+ return "Hello, world!"
121
+
122
+ manager = PromptManager(duplicate_behavior=DuplicateBehavior.ERROR)
123
+ prompt = Prompt.from_function(fn)
124
+ manager.add_prompt(prompt)
125
+
126
+ with pytest.raises(ValueError, match="Prompt already exists"):
127
+ manager.add_prompt(prompt)
128
+
129
+ def test_replace_duplicate_prompts(self):
130
+ """Test replacing duplicate prompts."""
131
+
132
+ def fn1() -> str:
133
+ return "Original"
134
+
135
+ def fn2() -> str:
136
+ return "Replacement"
137
+
138
+ manager = PromptManager(duplicate_behavior=DuplicateBehavior.REPLACE)
139
+ prompt1 = Prompt.from_function(fn1, name="test_prompt")
140
+ prompt2 = Prompt.from_function(fn2, name="test_prompt")
141
+
142
+ manager.add_prompt(prompt1)
143
+ manager.add_prompt(prompt2)
144
+
145
+ # Should have replaced the first prompt with the second
146
+ stored_prompt = manager.get_prompt("test_prompt")
147
+ assert stored_prompt == prompt2
148
+
149
+
150
+ class TestPromptTags:
151
+ """Test functionality related to prompt tags."""
152
+
153
+ def test_add_prompt_with_tags(self):
154
+ """Test adding a prompt with tags."""
155
+
156
+ def greeting() -> str:
157
+ return "Hello, world!"
158
+
159
+ manager = PromptManager()
160
+ prompt = Prompt.from_function(greeting, tags={"greeting", "simple"})
161
+ manager.add_prompt(prompt)
162
+
163
+ prompt = manager.get_prompt("greeting")
164
+ assert prompt is not None
165
+ assert prompt.tags == {"greeting", "simple"}
166
+
167
+ def test_add_prompt_with_empty_tags(self):
168
+ """Test adding a prompt with empty tags."""
169
+
170
+ def greeting() -> str:
171
+ return "Hello, world!"
172
+
173
+ manager = PromptManager()
174
+ prompt = Prompt.from_function(greeting, tags=set())
175
+ manager.add_prompt(prompt)
176
+
177
+ prompt = manager.get_prompt("greeting")
178
+ assert prompt is not None
179
+ assert prompt.tags == set()
180
+
181
+ def test_add_prompt_with_none_tags(self):
182
+ """Test adding a prompt with None tags."""
183
+
184
+ def greeting() -> str:
185
+ return "Hello, world!"
186
+
187
+ manager = PromptManager()
188
+ prompt = Prompt.from_function(greeting, tags=None)
189
+ manager.add_prompt(prompt)
190
+
191
+ prompt = manager.get_prompt("greeting")
192
+ assert prompt is not None
193
+ assert prompt.tags == set()
194
+
195
+ def test_list_prompts_with_tags(self):
196
+ """Test listing prompts with specific tags."""
197
+
198
+ def greeting() -> str:
199
+ return "Hello, world!"
200
+
201
+ def weather(location: str) -> str:
202
+ return f"Weather for {location}"
203
+
204
+ def summary(text: str) -> str:
205
+ return f"Summary of: {text}"
206
+
207
+ manager = PromptManager()
208
+ manager.add_prompt(Prompt.from_function(greeting, tags={"greeting", "simple"}))
209
+ manager.add_prompt(Prompt.from_function(weather, tags={"weather", "location"}))
210
+ manager.add_prompt(
211
+ Prompt.from_function(summary, tags={"summary", "nlp", "simple"})
212
+ )
213
+
214
+ # Filter prompts by tags
215
+ simple_prompts = [p for p in manager.list_prompts() if "simple" in p.tags]
216
+ assert len(simple_prompts) == 2
217
+ assert {p.name for p in simple_prompts} == {"greeting", "summary"}
218
+
219
+ nlp_prompts = [p for p in manager.list_prompts() if "nlp" in p.tags]
220
+ assert len(nlp_prompts) == 1
221
+ assert nlp_prompts[0].name == "summary"
222
+
223
+ def test_import_prompts_preserves_tags(self):
224
+ """Test that importing prompts preserves their tags."""
225
+ source_manager = PromptManager()
226
+
227
+ def sample_prompt() -> str:
228
+ return "Sample prompt"
229
+
230
+ source_manager.add_prompt(
231
+ Prompt.from_function(sample_prompt, tags={"example", "test"})
232
+ )
233
+
234
+ target_manager = PromptManager()
235
+ target_manager.import_prompts(source_manager, "imported/")
236
+
237
+ imported_prompt = target_manager.get_prompt("imported/sample_prompt")
238
+ assert imported_prompt is not None
239
+ assert imported_prompt.tags == {"example", "test"}
240
+
241
 
242
  class TestImports:
243
  def test_import_prompts(self):
 
245
  # Setup source manager with prompts
246
  source_manager = PromptManager()
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  summary_prompt = Prompt(
249
  name="summary",
250
  description="Generate a summary of text",
251
  arguments=[PromptArgument(name="text", description="Text to summarize")],
252
+ fn=lambda: None, # type: ignore
253
  )
254
+ source_manager.add_prompt(summary_prompt)
255
 
256
  translate_prompt = Prompt(
257
  name="translate",
 
260
  PromptArgument(name="text", description="Text to translate"),
261
  PromptArgument(name="language", description="Target language"),
262
  ],
263
+ fn=lambda: None, # type: ignore
264
  )
265
+ source_manager.add_prompt(translate_prompt)
266
 
267
  # Create target manager
268
  target_manager = PromptManager()
 
279
  assert "summary" in source_manager._prompts
280
  assert "translate" in source_manager._prompts
281
 
282
+ assert target_manager._prompts["nlp/summary"].fn == summary_prompt.fn
283
+ assert target_manager._prompts["nlp/translate"].fn == translate_prompt.fn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
  def test_import_prompts_with_duplicates(self):
286
  """Test handling of duplicate prompts during import."""
 
288
  source_manager = PromptManager()
289
  target_manager = PromptManager()
290
 
 
 
 
 
 
 
 
291
  source_prompt = Prompt(
292
  name="common",
293
  description="Source description",
294
  arguments=None,
295
+ fn=lambda: None, # type: ignore
296
  )
297
  source_manager._prompts["common"] = source_prompt
298
 
 
300
  name="common",
301
  description="Target description",
302
  arguments=None,
303
+ fn=lambda: None, # type: ignore
304
  )
305
  target_manager._prompts["common"] = target_prompt
306
 
 
312
  assert "common" in target_manager._prompts
313
  assert "external/common" in target_manager._prompts
314
 
315
+ assert target_manager._prompts["common"].fn == target_prompt.fn
316
+ assert target_manager._prompts["external/common"].fn == source_prompt.fn
 
 
 
 
 
 
 
317
 
318
  def test_import_prompts_with_nested_prefixes(self):
319
  """Test importing already prefixed prompts."""
 
322
  second_manager = PromptManager()
323
  third_manager = PromptManager()
324
 
 
 
 
 
 
 
325
  original_prompt = Prompt(
326
  name="analyze",
327
  description="Analyze text",
328
  arguments=[PromptArgument(name="text", description="Text to analyze")],
329
+ fn=lambda: None, # type: ignore
330
  )
331
  first_manager._prompts["analyze"] = original_prompt
332
 
 
340
  assert "text/analyze" in second_manager._prompts
341
  assert "ai/text/analyze" in third_manager._prompts
342
 
343
+ assert third_manager._prompts["ai/text/analyze"].fn == original_prompt.fn
 
 
 
 
 
 
 
 
 
tests/resources/test_resource_manager.py CHANGED
@@ -10,6 +10,7 @@ from fastmcp.resources import (
10
  ResourceManager,
11
  ResourceTemplate,
12
  )
 
13
 
14
 
15
  @pytest.fixture
@@ -59,7 +60,7 @@ class TestResourceManager:
59
 
60
  def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
61
  """Test warning on duplicate resources."""
62
- manager = ResourceManager()
63
  resource = FileResource(
64
  uri=FileUrl(f"file://{temp_file}"),
65
  name="test",
@@ -71,7 +72,7 @@ class TestResourceManager:
71
 
72
  def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
73
  """Test disabling warning on duplicate resources."""
74
- manager = ResourceManager(warn_on_duplicate_resources=False)
75
  resource = FileResource(
76
  uri=FileUrl(f"file://{temp_file}"),
77
  name="test",
@@ -81,6 +82,43 @@ class TestResourceManager:
81
  manager.add_resource(resource)
82
  assert "Resource already exists" not in caplog.text
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  @pytest.mark.anyio
85
  async def test_get_resource(self, temp_file: Path):
86
  """Test getting a resource by URI."""
@@ -141,6 +179,162 @@ class TestResourceManager:
141
  assert resources == [resource1, resource2]
142
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  class TestImports:
145
  def test_import_resources(self):
146
  """Test importing resources from one manager to another with a prefix."""
 
10
  ResourceManager,
11
  ResourceTemplate,
12
  )
13
+ from fastmcp.settings import DuplicateBehavior
14
 
15
 
16
  @pytest.fixture
 
60
 
61
  def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
62
  """Test warning on duplicate resources."""
63
+ manager = ResourceManager(duplicate_behavior=DuplicateBehavior.WARN)
64
  resource = FileResource(
65
  uri=FileUrl(f"file://{temp_file}"),
66
  name="test",
 
72
 
73
  def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
74
  """Test disabling warning on duplicate resources."""
75
+ manager = ResourceManager(duplicate_behavior=DuplicateBehavior.IGNORE)
76
  resource = FileResource(
77
  uri=FileUrl(f"file://{temp_file}"),
78
  name="test",
 
82
  manager.add_resource(resource)
83
  assert "Resource already exists" not in caplog.text
84
 
85
+ def test_error_on_duplicate_resources(self, temp_file: Path):
86
+ """Test error on duplicate resources."""
87
+ manager = ResourceManager(duplicate_behavior=DuplicateBehavior.ERROR)
88
+ resource = FileResource(
89
+ uri=FileUrl(f"file://{temp_file}"),
90
+ name="test",
91
+ path=temp_file,
92
+ )
93
+ manager.add_resource(resource)
94
+
95
+ with pytest.raises(ValueError, match="Resource already exists"):
96
+ manager.add_resource(resource)
97
+
98
+ def test_replace_duplicate_resources(self, temp_file: Path):
99
+ """Test replacing duplicate resources."""
100
+ manager = ResourceManager(duplicate_behavior=DuplicateBehavior.REPLACE)
101
+
102
+ resource1 = FileResource(
103
+ uri=FileUrl(f"file://{temp_file}"),
104
+ name="test1",
105
+ path=temp_file,
106
+ )
107
+
108
+ resource2 = FileResource(
109
+ uri=FileUrl(f"file://{temp_file}"),
110
+ name="test2", # Different name
111
+ path=temp_file,
112
+ )
113
+
114
+ manager.add_resource(resource1)
115
+ manager.add_resource(resource2)
116
+
117
+ # Should have replaced the first resource with the second
118
+ resources = manager.list_resources()
119
+ assert len(resources) == 1
120
+ assert resources[0].name == "test2"
121
+
122
  @pytest.mark.anyio
123
  async def test_get_resource(self, temp_file: Path):
124
  """Test getting a resource by URI."""
 
179
  assert resources == [resource1, resource2]
180
 
181
 
182
+ class TestResourceTags:
183
+ """Test functionality related to resource tags."""
184
+
185
+ def test_add_resource_with_tags(self, temp_file: Path):
186
+ """Test adding a resource with tags."""
187
+ manager = ResourceManager()
188
+ resource = FileResource(
189
+ uri=FileUrl(f"file://{temp_file}"),
190
+ name="weather_data",
191
+ path=temp_file,
192
+ tags={"weather", "data"},
193
+ )
194
+ manager.add_resource(resource)
195
+
196
+ # Check that tags are preserved
197
+ resources = manager.list_resources()
198
+ assert len(resources) == 1
199
+ assert resources[0].tags == {"weather", "data"}
200
+
201
+ def test_add_function_resource_with_tags(self):
202
+ """Test adding a function resource with tags."""
203
+ manager = ResourceManager()
204
+
205
+ async def get_data():
206
+ return "Sample data"
207
+
208
+ resource = FunctionResource(
209
+ uri=AnyUrl("data://sample"),
210
+ name="sample_data",
211
+ description="Sample data resource",
212
+ mime_type="text/plain",
213
+ fn=get_data,
214
+ tags={"sample", "test", "data"},
215
+ )
216
+
217
+ manager.add_resource(resource)
218
+ resources = manager.list_resources()
219
+ assert len(resources) == 1
220
+ assert resources[0].tags == {"sample", "test", "data"}
221
+
222
+ def test_add_template_with_tags(self):
223
+ """Test adding a resource template with tags."""
224
+ manager = ResourceManager()
225
+
226
+ def user_data(user_id: str) -> str:
227
+ return f"Data for user {user_id}"
228
+
229
+ template = ResourceTemplate.from_function(
230
+ fn=user_data,
231
+ uri_template="users://{user_id}",
232
+ name="user_template",
233
+ description="Get user data by ID",
234
+ tags={"users", "template", "data"},
235
+ )
236
+
237
+ manager.add_template(template)
238
+ templates = manager.list_templates()
239
+ assert len(templates) == 1
240
+ assert templates[0].tags == {"users", "template", "data"}
241
+
242
+ def test_filter_resources_by_tags(self, temp_file: Path):
243
+ """Test filtering resources by tags."""
244
+ manager = ResourceManager()
245
+
246
+ # Create multiple resources with different tags
247
+ resource1 = FileResource(
248
+ uri=FileUrl(f"file://{temp_file}1"),
249
+ name="weather_data",
250
+ path=temp_file,
251
+ tags={"weather", "external"},
252
+ )
253
+
254
+ async def get_user_data():
255
+ return "User data"
256
+
257
+ resource2 = FunctionResource(
258
+ uri=AnyUrl("data://users"),
259
+ name="user_data",
260
+ fn=get_user_data,
261
+ tags={"users", "internal"},
262
+ )
263
+
264
+ async def get_system_data():
265
+ return "System data"
266
+
267
+ resource3 = FunctionResource(
268
+ uri=AnyUrl("data://system"),
269
+ name="system_data",
270
+ fn=get_system_data,
271
+ tags={"system", "internal"},
272
+ )
273
+
274
+ manager.add_resource(resource1)
275
+ manager.add_resource(resource2)
276
+ manager.add_resource(resource3)
277
+
278
+ # Filter resources by tags
279
+ internal_resources = [
280
+ r for r in manager.list_resources() if "internal" in r.tags
281
+ ]
282
+ assert len(internal_resources) == 2
283
+ assert {r.name for r in internal_resources} == {"user_data", "system_data"}
284
+
285
+ external_resources = [
286
+ r for r in manager.list_resources() if "external" in r.tags
287
+ ]
288
+ assert len(external_resources) == 1
289
+ assert external_resources[0].name == "weather_data"
290
+
291
+ def test_import_resources_preserves_tags(self):
292
+ """Test that importing resources preserves their tags."""
293
+ source_manager = ResourceManager()
294
+
295
+ async def get_data():
296
+ return "Tagged data"
297
+
298
+ resource = FunctionResource(
299
+ uri=AnyUrl("data://tagged"),
300
+ name="tagged_data",
301
+ fn=get_data,
302
+ tags={"test", "example", "data"},
303
+ )
304
+
305
+ source_manager.add_resource(resource)
306
+
307
+ target_manager = ResourceManager()
308
+ target_manager.import_resources(source_manager, "imported+")
309
+
310
+ imported_resources = target_manager.list_resources()
311
+ assert len(imported_resources) == 1
312
+ assert imported_resources[0].tags == {"test", "example", "data"}
313
+
314
+ def test_import_templates_preserves_tags(self):
315
+ """Test that importing templates preserves their tags."""
316
+ source_manager = ResourceManager()
317
+
318
+ def user_template(user_id: str) -> str:
319
+ return f"User {user_id}"
320
+
321
+ template = ResourceTemplate.from_function(
322
+ fn=user_template,
323
+ uri_template="users://{user_id}",
324
+ name="user_template",
325
+ tags={"users", "template", "test"},
326
+ )
327
+
328
+ source_manager.add_template(template)
329
+
330
+ target_manager = ResourceManager()
331
+ target_manager.import_templates(source_manager, "imported+")
332
+
333
+ imported_templates = target_manager.list_templates()
334
+ assert len(imported_templates) == 1
335
+ assert imported_templates[0].tags == {"users", "template", "test"}
336
+
337
+
338
  class TestImports:
339
  def test_import_resources(self):
340
  """Test importing resources from one manager to another with a prefix."""
tests/server/test_server.py CHANGED
@@ -17,7 +17,7 @@ from mcp.types import (
17
  from pydantic import AnyUrl, Field
18
 
19
  from fastmcp import Context, FastMCP
20
- from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
21
  from fastmcp.resources import FileResource, FunctionResource
22
  from fastmcp.utilities.types import Image
23
 
@@ -482,7 +482,7 @@ class TestContextInjection:
482
  def tool_with_context(x: int, ctx: Context) -> str:
483
  return f"Request {ctx.request_id}: {x}"
484
 
485
- tool = mcp._tool_manager.add_tool(tool_with_context)
486
  assert tool.context_kwarg == "ctx"
487
 
488
  async def test_context_injection(self):
 
17
  from pydantic import AnyUrl, Field
18
 
19
  from fastmcp import Context, FastMCP
20
+ from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
21
  from fastmcp.resources import FileResource, FunctionResource
22
  from fastmcp.utilities.types import Image
23
 
 
482
  def tool_with_context(x: int, ctx: Context) -> str:
483
  return f"Request {ctx.request_id}: {x}"
484
 
485
+ tool = mcp._tool_manager.add_tool_from_fn(tool_with_context)
486
  assert tool.context_kwarg == "ctx"
487
 
488
  async def test_context_injection(self):
tests/tools/test_tool_manager.py CHANGED
@@ -5,6 +5,7 @@ import pytest
5
  from pydantic import BaseModel
6
 
7
  from fastmcp.exceptions import ToolError
 
8
  from fastmcp.tools import ToolManager
9
 
10
 
@@ -17,7 +18,7 @@ class TestAddTools:
17
  return a + b
18
 
19
  manager = ToolManager()
20
- manager.add_tool(add)
21
 
22
  tool = manager.get_tool("add")
23
  assert tool is not None
@@ -36,7 +37,7 @@ class TestAddTools:
36
  return f"Data from {url}"
37
 
38
  manager = ToolManager()
39
- manager.add_tool(fetch_data)
40
 
41
  tool = manager.get_tool("fetch_data")
42
  assert tool is not None
@@ -57,7 +58,7 @@ class TestAddTools:
57
  return {"id": 1, **user.model_dump()}
58
 
59
  manager = ToolManager()
60
- manager.add_tool(create_user)
61
 
62
  tool = manager.get_tool("create_user")
63
  assert tool is not None
@@ -71,11 +72,11 @@ class TestAddTools:
71
  def test_add_invalid_tool(self):
72
  manager = ToolManager()
73
  with pytest.raises(AttributeError):
74
- manager.add_tool(1) # type: ignore
75
 
76
  def test_add_lambda(self):
77
  manager = ToolManager()
78
- tool = manager.add_tool(lambda x: x, name="my_tool")
79
  assert tool.name == "my_tool"
80
 
81
  def test_add_lambda_with_no_name(self):
@@ -83,7 +84,7 @@ class TestAddTools:
83
  with pytest.raises(
84
  ValueError, match="You must provide a name for lambda functions"
85
  ):
86
- manager.add_tool(lambda x: x)
87
 
88
  def test_warn_on_duplicate_tools(self, caplog):
89
  """Test warning on duplicate tools."""
@@ -91,10 +92,10 @@ class TestAddTools:
91
  def f(x: int) -> int:
92
  return x
93
 
94
- manager = ToolManager()
95
- manager.add_tool(f)
96
  with caplog.at_level(logging.WARNING):
97
- manager.add_tool(f)
98
  assert "Tool already exists: f" in caplog.text
99
 
100
  def test_disable_warn_on_duplicate_tools(self, caplog):
@@ -103,13 +104,132 @@ class TestAddTools:
103
  def f(x: int) -> int:
104
  return x
105
 
106
- manager = ToolManager()
107
- manager.add_tool(f)
108
- manager.warn_on_duplicate_tools = False
109
  with caplog.at_level(logging.WARNING):
110
- manager.add_tool(f)
111
  assert "Tool already exists: f" not in caplog.text
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  class TestCallTools:
115
  @pytest.mark.anyio
@@ -119,7 +239,7 @@ class TestCallTools:
119
  return a + b
120
 
121
  manager = ToolManager()
122
- manager.add_tool(add)
123
  result = await manager.call_tool("add", {"a": 1, "b": 2})
124
  assert result == 3
125
 
@@ -130,7 +250,7 @@ class TestCallTools:
130
  return n * 2
131
 
132
  manager = ToolManager()
133
- manager.add_tool(double)
134
  result = await manager.call_tool("double", {"n": 5})
135
  assert result == 10
136
 
@@ -141,7 +261,7 @@ class TestCallTools:
141
  return a + b
142
 
143
  manager = ToolManager()
144
- manager.add_tool(add)
145
  result = await manager.call_tool("add", {"a": 1})
146
  assert result == 2
147
 
@@ -152,7 +272,7 @@ class TestCallTools:
152
  return a + b
153
 
154
  manager = ToolManager()
155
- manager.add_tool(add)
156
  with pytest.raises(ToolError):
157
  await manager.call_tool("add", {"a": 1})
158
 
@@ -168,7 +288,7 @@ class TestCallTools:
168
  return sum(vals)
169
 
170
  manager = ToolManager()
171
- manager.add_tool(sum_vals)
172
  # Try both with plain list and with JSON list
173
  result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
174
  assert result == 6
@@ -181,7 +301,7 @@ class TestCallTools:
181
  return vals if isinstance(vals, str) else "".join(vals)
182
 
183
  manager = ToolManager()
184
- manager.add_tool(concat_strs)
185
  # Try both with plain python object and with JSON list
186
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
187
  assert result == "abc"
@@ -207,7 +327,7 @@ class TestCallTools:
207
  return [x.name for x in tank.shrimp]
208
 
209
  manager = ToolManager()
210
- manager.add_tool(name_shrimp)
211
  result = await manager.call_tool(
212
  "name_shrimp",
213
  {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
@@ -229,7 +349,7 @@ class TestToolSchema:
229
  return a
230
 
231
  manager = ToolManager()
232
- tool = manager.add_tool(something)
233
  assert "ctx" not in json.dumps(tool.parameters)
234
  assert "Context" not in json.dumps(tool.parameters)
235
  assert "ctx" not in tool.fn_metadata.arg_model.model_fields
@@ -247,13 +367,13 @@ class TestContextHandling:
247
  return str(x)
248
 
249
  manager = ToolManager()
250
- tool = manager.add_tool(tool_with_context)
251
  assert tool.context_kwarg == "ctx"
252
 
253
  def tool_without_context(x: int) -> str:
254
  return str(x)
255
 
256
- tool = manager.add_tool(tool_without_context)
257
  assert tool.context_kwarg is None
258
 
259
  @pytest.mark.anyio
@@ -266,7 +386,7 @@ class TestContextHandling:
266
  return str(x)
267
 
268
  manager = ToolManager()
269
- manager.add_tool(tool_with_context)
270
 
271
  mcp = FastMCP()
272
  ctx = mcp.get_context()
@@ -283,7 +403,7 @@ class TestContextHandling:
283
  return str(x)
284
 
285
  manager = ToolManager()
286
- manager.add_tool(async_tool)
287
 
288
  mcp = FastMCP()
289
  ctx = mcp.get_context()
@@ -299,7 +419,7 @@ class TestContextHandling:
299
  return str(x)
300
 
301
  manager = ToolManager()
302
- manager.add_tool(tool_with_context)
303
  # Should not raise an error when context is not provided
304
  result = await manager.call_tool("tool_with_context", {"x": 42})
305
  assert result == "42"
@@ -313,7 +433,7 @@ class TestContextHandling:
313
  raise ValueError("Test error")
314
 
315
  manager = ToolManager()
316
- manager.add_tool(tool_with_context)
317
 
318
  mcp = FastMCP()
319
  ctx = mcp.get_context()
@@ -335,8 +455,10 @@ class TestImportTools:
335
  return "Tool 2 result"
336
 
337
  # Add tools to source manager
338
- source_manager.add_tool(tool1_fn, name="get_data", description="Get some data")
339
- source_manager.add_tool(
 
 
340
  tool2_fn, name="process_data", description="Process the data"
341
  )
342
 
@@ -364,11 +486,8 @@ class TestImportTools:
364
 
365
  # Verify the tool functions were properly copied
366
  # We can't directly compare functions, so we'll check their __name__ attribute
367
- assert target_manager._tools["source/get_data"].fn.__name__ == tool1_fn.__name__
368
- assert (
369
- target_manager._tools["source/process_data"].fn.__name__
370
- == tool2_fn.__name__
371
- )
372
 
373
  def test_tool_duplicate_behavior(self):
374
  """Test the behavior when importing tools with duplicate names."""
@@ -383,8 +502,8 @@ class TestImportTools:
383
  def target_fn():
384
  return "Target result"
385
 
386
- source_manager.add_tool(source_fn, name="common_tool")
387
- target_manager.add_tool(
388
  target_fn, name="source/common_tool"
389
  ) # Pre-create with the prefixed name
390
 
@@ -392,10 +511,7 @@ class TestImportTools:
392
  target_manager.import_tools(source_manager, "source/")
393
 
394
  # The original tool in the target manager is replaced by the imported one
395
- assert (
396
- target_manager._tools["source/common_tool"].fn.__name__
397
- == source_fn.__name__
398
- )
399
 
400
  def test_import_tools_with_multiple_prefixes(self):
401
  """Test importing tools from multiple managers with different prefixes."""
@@ -410,8 +526,8 @@ class TestImportTools:
410
  def headlines_fn():
411
  return "News headlines"
412
 
413
- weather_manager.add_tool(forecast_fn, name="forecast")
414
- news_manager.add_tool(headlines_fn, name="headlines")
415
 
416
  # Create target manager and import from both sources
417
  main_manager = ToolManager()
 
5
  from pydantic import BaseModel
6
 
7
  from fastmcp.exceptions import ToolError
8
+ from fastmcp.settings import DuplicateBehavior
9
  from fastmcp.tools import ToolManager
10
 
11
 
 
18
  return a + b
19
 
20
  manager = ToolManager()
21
+ manager.add_tool_from_fn(add)
22
 
23
  tool = manager.get_tool("add")
24
  assert tool is not None
 
37
  return f"Data from {url}"
38
 
39
  manager = ToolManager()
40
+ manager.add_tool_from_fn(fetch_data)
41
 
42
  tool = manager.get_tool("fetch_data")
43
  assert tool is not None
 
58
  return {"id": 1, **user.model_dump()}
59
 
60
  manager = ToolManager()
61
+ manager.add_tool_from_fn(create_user)
62
 
63
  tool = manager.get_tool("create_user")
64
  assert tool is not None
 
72
  def test_add_invalid_tool(self):
73
  manager = ToolManager()
74
  with pytest.raises(AttributeError):
75
+ manager.add_tool_from_fn(1) # type: ignore
76
 
77
  def test_add_lambda(self):
78
  manager = ToolManager()
79
+ tool = manager.add_tool_from_fn(lambda x: x, name="my_tool")
80
  assert tool.name == "my_tool"
81
 
82
  def test_add_lambda_with_no_name(self):
 
84
  with pytest.raises(
85
  ValueError, match="You must provide a name for lambda functions"
86
  ):
87
+ manager.add_tool_from_fn(lambda x: x)
88
 
89
  def test_warn_on_duplicate_tools(self, caplog):
90
  """Test warning on duplicate tools."""
 
92
  def f(x: int) -> int:
93
  return x
94
 
95
+ manager = ToolManager(duplicate_behavior=DuplicateBehavior.WARN)
96
+ manager.add_tool_from_fn(f)
97
  with caplog.at_level(logging.WARNING):
98
+ manager.add_tool_from_fn(f)
99
  assert "Tool already exists: f" in caplog.text
100
 
101
  def test_disable_warn_on_duplicate_tools(self, caplog):
 
104
  def f(x: int) -> int:
105
  return x
106
 
107
+ manager = ToolManager(duplicate_behavior=DuplicateBehavior.IGNORE)
108
+ manager.add_tool_from_fn(f)
 
109
  with caplog.at_level(logging.WARNING):
110
+ manager.add_tool_from_fn(f)
111
  assert "Tool already exists: f" not in caplog.text
112
 
113
+ def test_error_on_duplicate_tools(self):
114
+ """Test error on duplicate tools."""
115
+
116
+ def f(x: int) -> int:
117
+ return x
118
+
119
+ manager = ToolManager(duplicate_behavior=DuplicateBehavior.ERROR)
120
+ manager.add_tool_from_fn(f)
121
+
122
+ with pytest.raises(ValueError, match="Tool already exists"):
123
+ manager.add_tool_from_fn(f)
124
+
125
+ def test_replace_duplicate_tools(self):
126
+ """Test replacing duplicate tools."""
127
+
128
+ def original_fn(x: int) -> int:
129
+ return x
130
+
131
+ def replacement_fn(x: int) -> int:
132
+ return x * 2
133
+
134
+ manager = ToolManager(duplicate_behavior=DuplicateBehavior.REPLACE)
135
+ manager.add_tool_from_fn(original_fn, name="test_tool")
136
+ replacement_tool = manager.add_tool_from_fn(replacement_fn, name="test_tool")
137
+
138
+ # Should have replaced the first tool with the second
139
+ stored_tool = manager.get_tool("test_tool")
140
+ assert stored_tool == replacement_tool
141
+
142
+
143
+ class TestToolTags:
144
+ """Test functionality related to tool tags."""
145
+
146
+ def test_add_tool_with_tags(self):
147
+ """Test adding tags to a tool."""
148
+
149
+ def example_tool(x: int) -> int:
150
+ """An example tool with tags."""
151
+ return x * 2
152
+
153
+ manager = ToolManager()
154
+ tool = manager.add_tool_from_fn(example_tool, tags={"math", "utility"})
155
+
156
+ assert tool.tags == {"math", "utility"}
157
+ tool = manager.get_tool("example_tool")
158
+ assert tool is not None
159
+ assert tool.tags == {"math", "utility"}
160
+
161
+ def test_add_tool_with_empty_tags(self):
162
+ """Test adding a tool with empty tags set."""
163
+
164
+ def example_tool(x: int) -> int:
165
+ """An example tool with empty tags."""
166
+ return x * 2
167
+
168
+ manager = ToolManager()
169
+ tool = manager.add_tool_from_fn(example_tool, tags=set())
170
+
171
+ assert tool.tags == set()
172
+
173
+ def test_add_tool_with_none_tags(self):
174
+ """Test adding a tool with None tags."""
175
+
176
+ def example_tool(x: int) -> int:
177
+ """An example tool with None tags."""
178
+ return x * 2
179
+
180
+ manager = ToolManager()
181
+ tool = manager.add_tool_from_fn(example_tool, tags=None)
182
+
183
+ assert tool.tags == set()
184
+
185
+ def test_list_tools_with_tags(self):
186
+ """Test listing tools with specific tags."""
187
+
188
+ def math_tool(x: int) -> int:
189
+ """A math tool."""
190
+ return x * 2
191
+
192
+ def string_tool(x: str) -> str:
193
+ """A string tool."""
194
+ return x.upper()
195
+
196
+ def mixed_tool(x: int) -> str:
197
+ """A tool with multiple tags."""
198
+ return str(x)
199
+
200
+ manager = ToolManager()
201
+ manager.add_tool_from_fn(math_tool, tags={"math"})
202
+ manager.add_tool_from_fn(string_tool, tags={"string", "utility"})
203
+ manager.add_tool_from_fn(mixed_tool, tags={"math", "utility", "string"})
204
+
205
+ # Check if we can filter by tags when listing tools
206
+ math_tools = [tool for tool in manager.list_tools() if "math" in tool.tags]
207
+ assert len(math_tools) == 2
208
+ assert {tool.name for tool in math_tools} == {"math_tool", "mixed_tool"}
209
+
210
+ utility_tools = [
211
+ tool for tool in manager.list_tools() if "utility" in tool.tags
212
+ ]
213
+ assert len(utility_tools) == 2
214
+ assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
215
+
216
+ def test_import_tools_preserves_tags(self):
217
+ """Test that importing tools preserves their tags."""
218
+
219
+ def tagged_tool(x: int) -> int:
220
+ """A tool with tags."""
221
+ return x
222
+
223
+ source_manager = ToolManager()
224
+ source_manager.add_tool_from_fn(tagged_tool, tags={"test", "example"})
225
+
226
+ target_manager = ToolManager()
227
+ target_manager.import_tools(source_manager, "source/")
228
+
229
+ imported_tool = target_manager.get_tool("source/tagged_tool")
230
+ assert imported_tool is not None
231
+ assert imported_tool.tags == {"test", "example"}
232
+
233
 
234
  class TestCallTools:
235
  @pytest.mark.anyio
 
239
  return a + b
240
 
241
  manager = ToolManager()
242
+ manager.add_tool_from_fn(add)
243
  result = await manager.call_tool("add", {"a": 1, "b": 2})
244
  assert result == 3
245
 
 
250
  return n * 2
251
 
252
  manager = ToolManager()
253
+ manager.add_tool_from_fn(double)
254
  result = await manager.call_tool("double", {"n": 5})
255
  assert result == 10
256
 
 
261
  return a + b
262
 
263
  manager = ToolManager()
264
+ manager.add_tool_from_fn(add)
265
  result = await manager.call_tool("add", {"a": 1})
266
  assert result == 2
267
 
 
272
  return a + b
273
 
274
  manager = ToolManager()
275
+ manager.add_tool_from_fn(add)
276
  with pytest.raises(ToolError):
277
  await manager.call_tool("add", {"a": 1})
278
 
 
288
  return sum(vals)
289
 
290
  manager = ToolManager()
291
+ manager.add_tool_from_fn(sum_vals)
292
  # Try both with plain list and with JSON list
293
  result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
294
  assert result == 6
 
301
  return vals if isinstance(vals, str) else "".join(vals)
302
 
303
  manager = ToolManager()
304
+ manager.add_tool_from_fn(concat_strs)
305
  # Try both with plain python object and with JSON list
306
  result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
307
  assert result == "abc"
 
327
  return [x.name for x in tank.shrimp]
328
 
329
  manager = ToolManager()
330
+ manager.add_tool_from_fn(name_shrimp)
331
  result = await manager.call_tool(
332
  "name_shrimp",
333
  {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
 
349
  return a
350
 
351
  manager = ToolManager()
352
+ tool = manager.add_tool_from_fn(something)
353
  assert "ctx" not in json.dumps(tool.parameters)
354
  assert "Context" not in json.dumps(tool.parameters)
355
  assert "ctx" not in tool.fn_metadata.arg_model.model_fields
 
367
  return str(x)
368
 
369
  manager = ToolManager()
370
+ tool = manager.add_tool_from_fn(tool_with_context)
371
  assert tool.context_kwarg == "ctx"
372
 
373
  def tool_without_context(x: int) -> str:
374
  return str(x)
375
 
376
+ tool = manager.add_tool_from_fn(tool_without_context)
377
  assert tool.context_kwarg is None
378
 
379
  @pytest.mark.anyio
 
386
  return str(x)
387
 
388
  manager = ToolManager()
389
+ manager.add_tool_from_fn(tool_with_context)
390
 
391
  mcp = FastMCP()
392
  ctx = mcp.get_context()
 
403
  return str(x)
404
 
405
  manager = ToolManager()
406
+ manager.add_tool_from_fn(async_tool)
407
 
408
  mcp = FastMCP()
409
  ctx = mcp.get_context()
 
419
  return str(x)
420
 
421
  manager = ToolManager()
422
+ manager.add_tool_from_fn(tool_with_context)
423
  # Should not raise an error when context is not provided
424
  result = await manager.call_tool("tool_with_context", {"x": 42})
425
  assert result == "42"
 
433
  raise ValueError("Test error")
434
 
435
  manager = ToolManager()
436
+ manager.add_tool_from_fn(tool_with_context)
437
 
438
  mcp = FastMCP()
439
  ctx = mcp.get_context()
 
455
  return "Tool 2 result"
456
 
457
  # Add tools to source manager
458
+ source_manager.add_tool_from_fn(
459
+ tool1_fn, name="get_data", description="Get some data"
460
+ )
461
+ source_manager.add_tool_from_fn(
462
  tool2_fn, name="process_data", description="Process the data"
463
  )
464
 
 
486
 
487
  # Verify the tool functions were properly copied
488
  # We can't directly compare functions, so we'll check their __name__ attribute
489
+ assert target_manager._tools["source/get_data"].fn == tool1_fn
490
+ assert target_manager._tools["source/process_data"].fn == tool2_fn
 
 
 
491
 
492
  def test_tool_duplicate_behavior(self):
493
  """Test the behavior when importing tools with duplicate names."""
 
502
  def target_fn():
503
  return "Target result"
504
 
505
+ source_manager.add_tool_from_fn(source_fn, name="common_tool")
506
+ target_manager.add_tool_from_fn(
507
  target_fn, name="source/common_tool"
508
  ) # Pre-create with the prefixed name
509
 
 
511
  target_manager.import_tools(source_manager, "source/")
512
 
513
  # The original tool in the target manager is replaced by the imported one
514
+ assert target_manager._tools["source/common_tool"].fn == source_fn
 
 
 
515
 
516
  def test_import_tools_with_multiple_prefixes(self):
517
  """Test importing tools from multiple managers with different prefixes."""
 
526
  def headlines_fn():
527
  return "News headlines"
528
 
529
+ weather_manager.add_tool_from_fn(forecast_fn, name="forecast")
530
+ news_manager.add_tool_from_fn(headlines_fn, name="headlines")
531
 
532
  # Create target manager and import from both sources
533
  main_manager = ToolManager()