Fix bug with duplicate behavior == ignore

This commit is contained in:
Jeremiah Lowin 2025-04-15 09:33:23 -04:00
commit 6e309e1354
12 changed files with 333 additions and 116 deletions

View file

@ -314,12 +314,12 @@ from fastmcp.settings import DuplicateBehavior
mcp = FastMCP(
name="ConfiguredServer",
port=8080, # Directly maps to ServerSettings
on_duplicate_tools=DuplicateBehavior.ERROR # Set duplicate handling
on_duplicate_tools="error" # Set duplicate handling
)
# Settings are accessible via mcp.settings
print(mcp.settings.port) # Output: 8080
print(mcp.settings.on_duplicate_tools) # Output: DuplicateBehavior.ERROR
print(mcp.settings.on_duplicate_tools) # Output: "error"
```
### Key Configuration Options

View file

@ -209,7 +209,7 @@ from fastmcp.settings import DuplicateBehavior
mcp = FastMCP(
name="PromptServer",
on_duplicate_prompts=DuplicateBehavior.ERROR # Raise an error if a prompt name is duplicated
on_duplicate_prompts="error" # Raise an error if a prompt name is duplicated
)
@mcp.prompt()

View file

@ -301,7 +301,7 @@ from fastmcp.settings import DuplicateBehavior
mcp = FastMCP(
name="ResourceServer",
on_duplicate_resources=DuplicateBehavior.ERROR # Raise error on duplicates
on_duplicate_resources="error" # Raise error on duplicates
)
@mcp.resource("data://config")

View file

@ -250,7 +250,7 @@ from fastmcp.settings import DuplicateBehavior
mcp = FastMCP(
name="ResourceServer",
on_duplicate_resources=DuplicateBehavior.ERROR # Raise error on duplicates
on_duplicate_resources="error" # Raise error on duplicates
)
@mcp.resource("data://config")

View file

@ -315,7 +315,7 @@ from fastmcp.settings import DuplicateBehavior
mcp = FastMCP(
name="StrictServer",
# Configure behavior for duplicate tool names
on_duplicate_tools=DuplicateBehavior.ERROR
on_duplicate_tools="error"
)
@mcp.tool()

View file

@ -15,8 +15,19 @@ logger = get_logger(__name__)
class PromptManager:
"""Manages FastMCP prompts."""
def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
self._prompts: dict[str, Prompt] = {}
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
def get_prompt(self, name: str) -> Prompt | None:
@ -44,17 +55,17 @@ class PromptManager:
# Check for duplicates
existing = self._prompts.get(prompt.name)
if existing:
if self.duplicate_behavior == DuplicateBehavior.WARN:
if self.duplicate_behavior == "warn":
logger.warning(f"Prompt already exists: {prompt.name}")
self._prompts[prompt.name] = prompt
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
elif self.duplicate_behavior == "replace":
self._prompts[prompt.name] = prompt
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
elif self.duplicate_behavior == "error":
raise ValueError(f"Prompt already exists: {prompt.name}")
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
pass
self._prompts[prompt.name] = prompt
elif self.duplicate_behavior == "ignore":
return existing
else:
self._prompts[prompt.name] = prompt
return prompt
async def render_prompt(

View file

@ -19,9 +19,20 @@ logger = get_logger(__name__)
class ResourceManager:
"""Manages FastMCP resources."""
def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
self._resources: dict[str, Resource] = {}
self._templates: dict[str, ResourceTemplate] = {}
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
def add_resource_or_template_from_fn(
@ -114,16 +125,17 @@ class ResourceManager:
)
existing = self._resources.get(str(resource.uri))
if existing:
if self.duplicate_behavior == DuplicateBehavior.WARN:
if self.duplicate_behavior == "warn":
logger.warning(f"Resource already exists: {resource.uri}")
self._resources[str(resource.uri)] = resource
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
elif self.duplicate_behavior == "replace":
self._resources[str(resource.uri)] = resource
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
elif self.duplicate_behavior == "error":
raise ValueError(f"Resource already exists: {resource.uri}")
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
pass
self._resources[str(resource.uri)] = resource
elif self.duplicate_behavior == "ignore":
return existing
else:
self._resources[str(resource.uri)] = resource
return resource
def add_template_from_fn(
@ -167,16 +179,17 @@ class ResourceManager:
)
existing = self._templates.get(str(template.uri_template))
if existing:
if self.duplicate_behavior == DuplicateBehavior.WARN:
if self.duplicate_behavior == "warn":
logger.warning(f"Resource already exists: {template.uri_template}")
self._templates[str(template.uri_template)] = template
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
elif self.duplicate_behavior == "replace":
self._templates[str(template.uri_template)] = template
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
elif self.duplicate_behavior == "error":
raise ValueError(f"Resource already exists: {template.uri_template}")
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
pass
self._templates[template.uri_template] = template
elif self.duplicate_behavior == "ignore":
return existing
else:
self._templates[template.uri_template] = template
return template
async def get_resource(self, uri: AnyUrl | str) -> Resource | None:

View file

@ -1,6 +1,5 @@
from __future__ import annotations as _annotations
from enum import Enum
from typing import TYPE_CHECKING, Literal
from pydantic import Field
@ -11,12 +10,7 @@ if TYPE_CHECKING:
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class DuplicateBehavior(Enum):
WARN = "warn"
ERROR = "error"
REPLACE = "replace"
IGNORE = "ignore"
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
class Settings(BaseSettings):
@ -55,13 +49,13 @@ class ServerSettings(BaseSettings):
debug: bool = False
# resource settings
on_duplicate_resources: DuplicateBehavior = DuplicateBehavior.WARN
on_duplicate_resources: DuplicateBehavior = "warn"
# tool settings
on_duplicate_tools: DuplicateBehavior = DuplicateBehavior.WARN
on_duplicate_tools: DuplicateBehavior = "warn"
# prompt settings
on_duplicate_prompts: DuplicateBehavior = DuplicateBehavior.WARN
on_duplicate_prompts: DuplicateBehavior = "warn"
dependencies: list[str] = Field(
default_factory=list,

View file

@ -21,8 +21,19 @@ logger = get_logger(__name__)
class ToolManager:
"""Manages FastMCP tools."""
def __init__(self, duplicate_behavior: DuplicateBehavior = DuplicateBehavior.WARN):
def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
self._tools: dict[str, Tool] = {}
# Default to "warn" if None is provided
if duplicate_behavior is None:
duplicate_behavior = "warn"
if duplicate_behavior not in DuplicateBehavior.__args__:
raise ValueError(
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
def get_tool(self, name: str) -> Tool | None:
@ -57,16 +68,17 @@ class ToolManager:
name = name or tool.name
existing = self._tools.get(name)
if existing:
if self.duplicate_behavior == DuplicateBehavior.WARN:
if self.duplicate_behavior == "warn":
logger.warning(f"Tool already exists: {name}")
self._tools[name] = tool
elif self.duplicate_behavior == DuplicateBehavior.REPLACE:
elif self.duplicate_behavior == "replace":
self._tools[name] = tool
elif self.duplicate_behavior == DuplicateBehavior.ERROR:
elif self.duplicate_behavior == "error":
raise ValueError(f"Tool already exists: {name}")
elif self.duplicate_behavior == DuplicateBehavior.IGNORE:
pass
self._tools[name] = tool
elif self.duplicate_behavior == "ignore":
return existing
else:
self._tools[name] = tool
return tool
async def call_tool(

View file

@ -4,7 +4,6 @@ from fastmcp.exceptions import PromptError
from fastmcp.prompts import Prompt
from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
from fastmcp.prompts.prompt_manager import PromptManager
from fastmcp.settings import DuplicateBehavior
class TestPromptManager:
@ -26,7 +25,7 @@ class TestPromptManager:
def fn() -> str:
return "Hello, world!"
manager = PromptManager(duplicate_behavior=DuplicateBehavior.WARN)
manager = PromptManager(duplicate_behavior="warn")
prompt = Prompt.from_function(fn)
first = manager.add_prompt(prompt)
second = manager.add_prompt(prompt)
@ -39,13 +38,87 @@ class TestPromptManager:
def fn() -> str:
return "Hello, world!"
manager = PromptManager(duplicate_behavior=DuplicateBehavior.IGNORE)
manager = PromptManager(duplicate_behavior="ignore")
prompt = Prompt.from_function(fn)
first = manager.add_prompt(prompt)
second = manager.add_prompt(prompt)
assert first == second
assert "Prompt already exists" not in caplog.text
def test_warn_on_duplicate_prompts(self, caplog):
"""Test warning on duplicate prompts."""
manager = PromptManager(duplicate_behavior="warn")
def test_fn() -> str:
return "Test prompt"
prompt = Prompt.from_function(test_fn, name="test_prompt")
manager.add_prompt(prompt)
manager.add_prompt(prompt)
assert "Prompt already exists: test_prompt" in caplog.text
# Should have the prompt
assert manager.get_prompt("test_prompt") is not None
def test_error_on_duplicate_prompts(self):
"""Test error on duplicate prompts."""
manager = PromptManager(duplicate_behavior="error")
def test_fn() -> str:
return "Test prompt"
prompt = Prompt.from_function(test_fn, name="test_prompt")
manager.add_prompt(prompt)
with pytest.raises(ValueError, match="Prompt already exists: test_prompt"):
manager.add_prompt(prompt)
def test_replace_duplicate_prompts(self):
"""Test replacing duplicate prompts."""
manager = PromptManager(duplicate_behavior="replace")
def original_fn() -> str:
return "Original prompt"
def replacement_fn() -> str:
return "Replacement prompt"
prompt1 = Prompt.from_function(original_fn, name="test_prompt")
prompt2 = Prompt.from_function(replacement_fn, name="test_prompt")
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
# Should have replaced with the new prompt
prompt = manager.get_prompt("test_prompt")
assert prompt is not None
assert prompt.fn.__name__ == "replacement_fn"
def test_ignore_duplicate_prompts(self):
"""Test ignoring duplicate prompts."""
manager = PromptManager(duplicate_behavior="ignore")
def original_fn() -> str:
return "Original prompt"
def replacement_fn() -> str:
return "Replacement prompt"
prompt1 = Prompt.from_function(original_fn, name="test_prompt")
prompt2 = Prompt.from_function(replacement_fn, name="test_prompt")
manager.add_prompt(prompt1)
result = manager.add_prompt(prompt2)
# Should keep the original
prompt = manager.get_prompt("test_prompt")
assert prompt is not None
assert prompt.fn.__name__ == "original_fn"
# Result should be the original prompt
assert result.fn.__name__ == "original_fn"
def test_list_prompts(self):
"""Test listing all prompts."""
@ -114,39 +187,6 @@ class TestPromptManager:
with pytest.raises(ValueError, match="Missing required arguments"):
await manager.render_prompt("fn")
def test_error_on_duplicate_prompts(self):
"""Test error on duplicate prompts."""
def fn() -> str:
return "Hello, world!"
manager = PromptManager(duplicate_behavior=DuplicateBehavior.ERROR)
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
with pytest.raises(ValueError, match="Prompt already exists"):
manager.add_prompt(prompt)
def test_replace_duplicate_prompts(self):
"""Test replacing duplicate prompts."""
def fn1() -> str:
return "Original"
def fn2() -> str:
return "Replacement"
manager = PromptManager(duplicate_behavior=DuplicateBehavior.REPLACE)
prompt1 = Prompt.from_function(fn1, name="test_prompt")
prompt2 = Prompt.from_function(fn2, name="test_prompt")
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
# Should have replaced the first prompt with the second
stored_prompt = manager.get_prompt("test_prompt")
assert stored_prompt == prompt2
class TestPromptTags:
"""Test functionality related to prompt tags."""

View file

@ -11,7 +11,6 @@ from fastmcp.resources import (
ResourceManager,
ResourceTemplate,
)
from fastmcp.settings import DuplicateBehavior
@pytest.fixture
@ -61,19 +60,24 @@ class TestResourceManager:
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.WARN)
manager = ResourceManager(duplicate_behavior="warn")
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="test",
name="test_resource",
path=temp_file,
)
manager.add_resource(resource)
manager.add_resource(resource)
assert "Resource already exists" in caplog.text
# Should have the resource
assert len(manager.list_resources()) == 1
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test disabling warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.IGNORE)
manager = ResourceManager(duplicate_behavior="ignore")
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="test",
@ -85,12 +89,14 @@ class TestResourceManager:
def test_error_on_duplicate_resources(self, temp_file: Path):
"""Test error on duplicate resources."""
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.ERROR)
manager = ResourceManager(duplicate_behavior="error")
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="test",
name="test_resource",
path=temp_file,
)
manager.add_resource(resource)
with pytest.raises(ValueError, match="Resource already exists"):
@ -98,27 +104,153 @@ class TestResourceManager:
def test_replace_duplicate_resources(self, temp_file: Path):
"""Test replacing duplicate resources."""
manager = ResourceManager(duplicate_behavior=DuplicateBehavior.REPLACE)
manager = ResourceManager(duplicate_behavior="replace")
resource1 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="test1",
name="original",
path=temp_file,
)
resource2 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="test2", # Different name
name="replacement",
path=temp_file,
)
manager.add_resource(resource1)
manager.add_resource(resource2)
# Should have replaced the first resource with the second
# Should have replaced with the new resource
resources = manager.list_resources()
assert len(resources) == 1
assert resources[0].name == "test2"
assert resources[0].name == "replacement"
def test_ignore_duplicate_resources(self, temp_file: Path):
"""Test ignoring duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
resource1 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="original",
path=temp_file,
)
resource2 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
name="replacement",
path=temp_file,
)
manager.add_resource(resource1)
result = manager.add_resource(resource2)
# Should keep the original
resources = manager.list_resources()
assert len(resources) == 1
assert resources[0].name == "original"
# Result should be the original resource
assert result.name == "original"
def test_warn_on_duplicate_templates(self, caplog):
"""Test warning on duplicate templates."""
manager = ResourceManager(duplicate_behavior="warn")
def template_fn(id: str) -> str:
return f"Template {id}"
template = ResourceTemplate.from_function(
fn=template_fn,
uri_template="test://{id}",
name="test_template",
)
manager.add_template(template)
manager.add_template(template)
assert "Resource already exists" in caplog.text
# Should have the template
assert len(manager.list_templates()) == 1
def test_error_on_duplicate_templates(self):
"""Test error on duplicate templates."""
manager = ResourceManager(duplicate_behavior="error")
def template_fn(id: str) -> str:
return f"Template {id}"
template = ResourceTemplate.from_function(
fn=template_fn,
uri_template="test://{id}",
name="test_template",
)
manager.add_template(template)
with pytest.raises(ValueError, match="Resource already exists"):
manager.add_template(template)
def test_replace_duplicate_templates(self):
"""Test replacing duplicate templates."""
manager = ResourceManager(duplicate_behavior="replace")
def original_fn(id: str) -> str:
return f"Original {id}"
def replacement_fn(id: str) -> str:
return f"Replacement {id}"
template1 = ResourceTemplate.from_function(
fn=original_fn,
uri_template="test://{id}",
name="original",
)
template2 = ResourceTemplate.from_function(
fn=replacement_fn,
uri_template="test://{id}",
name="replacement",
)
manager.add_template(template1)
manager.add_template(template2)
# Should have replaced with the new template
templates = manager.list_templates()
assert len(templates) == 1
assert templates[0].name == "replacement"
def test_ignore_duplicate_templates(self):
"""Test ignoring duplicate templates."""
manager = ResourceManager(duplicate_behavior="ignore")
def original_fn(id: str) -> str:
return f"Original {id}"
def replacement_fn(id: str) -> str:
return f"Replacement {id}"
template1 = ResourceTemplate.from_function(
fn=original_fn,
uri_template="test://{id}",
name="original",
)
template2 = ResourceTemplate.from_function(
fn=replacement_fn,
uri_template="test://{id}",
name="replacement",
)
manager.add_template(template1)
result = manager.add_template(template2)
# Should keep the original
templates = manager.list_templates()
assert len(templates) == 1
assert templates[0].name == "original"
# Result should be the original template
assert result.name == "original"
@pytest.mark.anyio
async def test_get_resource(self, temp_file: Path):

View file

@ -5,7 +5,6 @@ import pytest
from pydantic import BaseModel
from fastmcp.exceptions import ToolError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import Tool
@ -88,15 +87,17 @@ class TestAddTools:
def test_warn_on_duplicate_tools(self, caplog):
"""Test warning on duplicate tools."""
manager = ToolManager(duplicate_behavior="warn")
def f(x: int) -> int:
def test_fn(x: int) -> int:
return x
manager = ToolManager(duplicate_behavior=DuplicateBehavior.WARN)
manager.add_tool_from_fn(f)
with caplog.at_level(logging.WARNING):
manager.add_tool_from_fn(f)
assert "Tool already exists: f" in caplog.text
manager.add_tool_from_fn(test_fn, name="test_tool")
manager.add_tool_from_fn(test_fn, name="test_tool")
assert "Tool already exists: test_tool" in caplog.text
# Should have the tool
assert manager.get_tool("test_tool") is not None
def test_disable_warn_on_duplicate_tools(self, caplog):
"""Test disabling warning on duplicate tools."""
@ -104,7 +105,7 @@ class TestAddTools:
def f(x: int) -> int:
return x
manager = ToolManager(duplicate_behavior=DuplicateBehavior.IGNORE)
manager = ToolManager(duplicate_behavior="ignore")
manager.add_tool_from_fn(f)
with caplog.at_level(logging.WARNING):
manager.add_tool_from_fn(f)
@ -112,18 +113,19 @@ class TestAddTools:
def test_error_on_duplicate_tools(self):
"""Test error on duplicate tools."""
manager = ToolManager(duplicate_behavior="error")
def f(x: int) -> int:
def test_fn(x: int) -> int:
return x
manager = ToolManager(duplicate_behavior=DuplicateBehavior.ERROR)
manager.add_tool_from_fn(f)
manager.add_tool_from_fn(test_fn, name="test_tool")
with pytest.raises(ValueError, match="Tool already exists"):
manager.add_tool_from_fn(f)
with pytest.raises(ValueError, match="Tool already exists: test_tool"):
manager.add_tool_from_fn(test_fn, name="test_tool")
def test_replace_duplicate_tools(self):
"""Test replacing duplicate tools."""
manager = ToolManager(duplicate_behavior="replace")
def original_fn(x: int) -> int:
return x
@ -131,20 +133,33 @@ class TestAddTools:
def replacement_fn(x: int) -> int:
return x * 2
manager = ToolManager(duplicate_behavior=DuplicateBehavior.REPLACE)
manager.add_tool_from_fn(original_fn, name="test_tool")
replacement_tool = manager.add_tool_from_fn(replacement_fn, name="test_tool")
manager.add_tool_from_fn(replacement_fn, name="test_tool")
# Should have replaced the first tool with the second
stored_tool = manager.get_tool("test_tool")
assert stored_tool is not None
assert stored_tool == replacement_tool
# Should have replaced with the new function
tool = manager.get_tool("test_tool")
assert tool is not None
assert tool.fn.__name__ == "replacement_fn"
# The name should still be the same
assert stored_tool.name == "test_tool"
def test_ignore_duplicate_tools(self):
"""Test ignoring duplicate tools."""
manager = ToolManager(duplicate_behavior="ignore")
# But the function is different
assert stored_tool.fn.__name__ == "replacement_fn"
def original_fn(x: int) -> int:
return x
def replacement_fn(x: int) -> int:
return x * 2
manager.add_tool_from_fn(original_fn, name="test_tool")
result = manager.add_tool_from_fn(replacement_fn, name="test_tool")
# Should keep the original
tool = manager.get_tool("test_tool")
assert tool is not None
assert tool.fn.__name__ == "original_fn"
# Result should be the original tool
assert result.fn.__name__ == "original_fn"
class TestToolTags:
@ -630,7 +645,7 @@ class TestCustomToolNames:
assert target_manager.get_tool("prefix/source_fn") is None
def test_replace_tool_keeps_original_name(self):
"""Test that replacing a tool with DuplicateBehavior.REPLACE keeps the original name."""
"""Test that replacing a tool with "replace" keeps the original name."""
def original_fn(x: int) -> int:
return x
@ -639,7 +654,7 @@ class TestCustomToolNames:
return x * 2
# Create a manager with REPLACE behavior
manager = ToolManager(duplicate_behavior=DuplicateBehavior.REPLACE)
manager = ToolManager(duplicate_behavior="replace")
# Add the original tool
original_tool = manager.add_tool_from_fn(original_fn, name="test_tool")