diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 24ca7b4ba..f720b672a 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -83,10 +83,12 @@ When you call `await main_mcp.import_server(subserver, prefix={whatever})`: 1. **Tools**: All tools from `subserver` are added to `main_mcp` with names prefixed using `{prefix}_`. - `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`. -2. **Resources**: All resources are added with URIs prefixed in the format `protocol://{prefix}/path`. - - `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="data://{prefix}/info")`. +2. **Resources**: All resources are added with both URIs and names prefixed. + - URI: `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="data://{prefix}/info")`. + - Name: `resource.name` becomes `"{prefix}_{resource.name}"`. 3. **Resource Templates**: Templates are prefixed similarly to resources. - - `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`. + - URI: `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`. + - Name: `template.name` becomes `"{prefix}_{template.name}"`. 4. **Prompts**: All prompts are added with names prefixed using `{prefix}_`. - `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`. @@ -196,7 +198,7 @@ When mounting is configured: 3. **Prefixed Access**: The parent server uses prefixes to route requests to the mounted server. 4. **Delegation**: Requests for components matching the prefix are delegated to the mounted server at runtime. -The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts. +The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts. This includes prefixing both the URIs/keys and the names of resources and templates for better identification in multi-server configurations. The `prefix` parameter is optional. If omitted, components are mounted without modification. diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index c373d01d1..a7fb12159 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -69,8 +69,8 @@ class PromptManager: child_dict = {p.key: p for p in child_results} if mounted.prefix: for prompt in child_dict.values(): - prefixed_prompt = prompt.with_key( - f"{mounted.prefix}_{prompt.key}" + prefixed_prompt = prompt.model_copy( + key=f"{mounted.prefix}_{prompt.key}" ) all_prompts[prefixed_prompt.key] = prefixed_prompt else: diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index aa6198111..7e031a414 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -101,8 +101,11 @@ class ResourceManager: prefixed_uri = add_resource_prefix( uri, mounted.prefix, mounted.resource_prefix_format ) - # Create a copy of the resource with the prefixed key - prefixed_resource = resource.with_key(prefixed_uri) + # Create a copy of the resource with the prefixed key and name + prefixed_resource = resource.model_copy( + update={"name": f"{mounted.prefix}_{resource.name}"}, + key=prefixed_uri, + ) all_resources[prefixed_uri] = prefixed_resource else: all_resources.update(child_resources) @@ -149,8 +152,11 @@ class ResourceManager: prefixed_uri_template = add_resource_prefix( uri_template, mounted.prefix, mounted.resource_prefix_format ) - # Create a copy of the template with the prefixed key - prefixed_template = template.with_key(prefixed_uri_template) + # Create a copy of the template with the prefixed key and name + prefixed_template = template.model_copy( + update={"name": f"{mounted.prefix}_{template.name}"}, + key=prefixed_uri_template, + ) all_templates[prefixed_uri_template] = prefixed_template else: all_templates.update(child_dict) @@ -273,7 +279,7 @@ class ResourceManager: Args: resource: A Resource instance to add. The resource's .key attribute will be used as the storage key. To overwrite it, call - Resource.with_key() before calling this method. + Resource.model_copy(key=new_key) before calling this method. """ existing = self._resources.get(resource.key) if existing: @@ -322,7 +328,7 @@ class ResourceManager: Args: template: A ResourceTemplate instance to add. The template's .key attribute will be used as the storage key. To overwrite it, call - ResourceTemplate.with_key() before calling this method. + ResourceTemplate.model_copy(key=new_key) before calling this method. Returns: The added template. If a template with the same URI already exists, diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c8d66b132..6727428cd 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1891,7 +1891,7 @@ class FastMCP(Generic[LifespanResultT]): # Import tools from the server for key, tool in (await server.get_tools()).items(): if prefix: - tool = tool.with_key(f"{prefix}_{key}") + tool = tool.model_copy(key=f"{prefix}_{key}") self._tool_manager.add_tool(tool) # Import resources and templates from the server @@ -1900,7 +1900,9 @@ class FastMCP(Generic[LifespanResultT]): resource_key = add_resource_prefix( key, prefix, self.resource_prefix_format ) - resource = resource.with_key(resource_key) + resource = resource.model_copy( + update={"name": f"{prefix}_{resource.name}"}, key=resource_key + ) self._resource_manager.add_resource(resource) for key, template in (await server.get_resource_templates()).items(): @@ -1908,13 +1910,15 @@ class FastMCP(Generic[LifespanResultT]): template_key = add_resource_prefix( key, prefix, self.resource_prefix_format ) - template = template.with_key(template_key) + template = template.model_copy( + update={"name": f"{prefix}_{template.name}"}, key=template_key + ) self._resource_manager.add_template(template) # Import prompts from the server for key, prompt in (await server.get_prompts()).items(): if prefix: - prompt = prompt.with_key(f"{prefix}_{key}") + prompt = prompt.model_copy(key=f"{prefix}_{key}") self._prompt_manager.add_prompt(prompt) if prefix: diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 18ea883c0..b460c2b22 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -75,7 +75,9 @@ class ToolManager: child_dict = {t.key: t for t in child_results} if mounted.prefix: for tool in child_dict.values(): - prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}") + prefixed_tool = tool.model_copy( + key=f"{mounted.prefix}_{tool.key}" + ) all_tools[prefixed_tool.key] = prefixed_tool else: all_tools.update(child_dict) diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index c127a8deb..8306b1e67 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -91,12 +91,27 @@ class FastMCPComponent(FastMCPBaseModel): return meta or None - def with_key(self, key: str) -> Self: + def model_copy( + self, + *, + update: dict[str, Any] | None = None, + deep: bool = False, + key: str | None = None, + ) -> Self: + """ + Create a copy of the component. + + Args: + update: A dictionary of fields to update. + deep: Whether to deep copy the component. + key: The key to use for the copy. + """ # `model_copy` has an `update` parameter but it doesn't work for certain private attributes # https://github.com/pydantic/pydantic/issues/12116 - # So we manually set the private attribute here instead - copy = self.model_copy() - copy._key = key + # So we manually set the private attribute here instead, such as _key + copy = super().model_copy(update=update, deep=deep) + if key is not None: + copy._key = key return copy def __eq__(self, other: object) -> bool: diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index ca8d66d69..95a050884 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -469,8 +469,8 @@ class TestCustomResourceKeys: fn=get_data, ) - # Use with_key to create a new resource with the custom key - resource_with_custom_key = resource.with_key(custom_key) + # Use model_copy to create a new resource with the custom key + resource_with_custom_key = resource.model_copy(key=custom_key) manager.add_resource(resource_with_custom_key) # Resource should be accessible via custom key @@ -496,8 +496,8 @@ class TestCustomResourceKeys: name="test_template", ) - # Use with_key to create a new template with the custom key - template_with_custom_key = template.with_key(custom_key) + # Use model_copy to create a new template with the custom key + template_with_custom_key = template.model_copy(key=custom_key) manager.add_template(template_with_custom_key) # Template should be accessible via custom key @@ -523,8 +523,8 @@ class TestCustomResourceKeys: fn=get_data, ) - # Use with_key to create a new resource with the custom key - resource_with_custom_key = resource.with_key(custom_key) + # Use model_copy to create a new resource with the custom key + resource_with_custom_key = resource.model_copy(key=custom_key) manager.add_resource(resource_with_custom_key) # Should be retrievable by the custom key @@ -552,8 +552,8 @@ class TestCustomResourceKeys: name="custom_greeter", ) - # Use with_key to create a new template with the custom key - template_with_custom_key = template.with_key(custom_key) + # Use model_copy to create a new template with the custom key + template_with_custom_key = template.model_copy(key=custom_key) manager.add_template(template_with_custom_key) # Using a URI that matches the custom key pattern diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 907574d7b..8392d9740 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -605,3 +605,41 @@ async def test_import_conflict_resolution_with_prefix(): result = await client.call_tool("api_shared_tool", {}) assert result.data == "Second app tool" + + +async def test_import_server_resource_name_prefixing(): + """Test that resource names are prefixed when using import_server.""" + # Create a sub-server with a resource + sub_server = FastMCP("SubServer") + + @sub_server.resource("resource://test_resource") + def test_resource() -> str: + return "Test content" + + # Create main server and import sub-server with prefix + main_server = FastMCP("MainServer") + await main_server.import_server(sub_server, prefix="imported") + + # Get resources and verify name prefixing + resources = await main_server.get_resources() + resource = resources["resource://imported/test_resource"] + assert resource.name == "imported_test_resource" + + +async def test_import_server_resource_template_name_prefixing(): + """Test that resource template names are prefixed when using import_server.""" + # Create a sub-server with a resource template + sub_server = FastMCP("SubServer") + + @sub_server.resource("resource://data/{item_id}") + def data_template(item_id: str) -> str: + return f"Data for {item_id}" + + # Create main server and import sub-server with prefix + main_server = FastMCP("MainServer") + await main_server.import_server(sub_server, prefix="imported") + + # Get resource templates and verify name prefixing + templates = await main_server.get_resource_templates() + template = templates["resource://imported/data/{item_id}"] + assert template.name == "imported_data_template" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 9312f0d4e..9fc642d9e 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -967,3 +967,55 @@ class TestAsProxyKwarg: # in the present implementation the sub server will be invoked 3 times # to call its tool assert lifespan_check.count("start") >= 2 + + +class TestResourceNamePrefixing: + """Test that resource and resource template names get prefixed when mounted.""" + + async def test_resource_name_prefixing(self): + """Test that resource names are prefixed when mounted.""" + + # Create a sub-app with a resource + sub_app = FastMCP("SubApp") + + @sub_app.resource("resource://my_resource") + def my_resource() -> str: + return "Resource content" + + # Create main app and mount sub-app with prefix + main_app = FastMCP("MainApp") + main_app.mount(sub_app, "prefix") + + # Get resources from main app + resources = await main_app.get_resources() + + # Should have prefixed key (using path format: resource://prefix/resource_name) + assert "resource://prefix/my_resource" in resources + + # The resource name should also be prefixed + resource = resources["resource://prefix/my_resource"] + assert resource.name == "prefix_my_resource" + + async def test_resource_template_name_prefixing(self): + """Test that resource template names are prefixed when mounted.""" + + # Create a sub-app with a resource template + sub_app = FastMCP("SubApp") + + @sub_app.resource("resource://user/{user_id}") + def user_template(user_id: str) -> str: + return f"User {user_id} data" + + # Create main app and mount sub-app with prefix + main_app = FastMCP("MainApp") + main_app.mount(sub_app, "prefix") + + # Get resource templates from main app + templates = await main_app.get_resource_templates() + + # Should have prefixed key (using path format: resource://prefix/template_uri) + assert "resource://prefix/user/{user_id}" in templates + + # The template name should also be prefixed + template = templates["resource://prefix/user/{user_id}"] + assert template.name == "prefix_user_template" diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 065d90607..13989276f 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -834,8 +834,8 @@ class TestCustomToolNames: # Create a tool with a specific name tool = Tool.from_function(fn, name="my_tool") manager = ToolManager() - # Use with_key to create a new tool with the custom key - tool_with_custom_key = tool.with_key("proxy_tool") + # Use model_copy to create a new tool with the custom key + tool_with_custom_key = tool.model_copy(key="proxy_tool") manager.add_tool(tool_with_custom_key) # The tool is accessible under the key stored = await manager.get_tool("proxy_tool") diff --git a/tests/utilities/test_components.py b/tests/utilities/test_components.py index c7ccc0632..1211d1e1f 100644 --- a/tests/utilities/test_components.py +++ b/tests/utilities/test_components.py @@ -123,9 +123,9 @@ class TestFastMCPComponent: result = component.get_meta(include_fastmcp_meta=False) assert result is None - def test_with_key_creates_copy_with_new_key(self, basic_component): - """Test that with_key creates a copy with a new key.""" - new_component = basic_component.with_key("new_key") + def test_model_copy_creates_copy_with_new_key(self, basic_component): + """Test that model_copy with key creates a copy with a new key.""" + new_component = basic_component.model_copy(key="new_key") assert new_component.key == "new_key" assert new_component.name == basic_component.name assert new_component is not basic_component # Should be a copy @@ -290,8 +290,8 @@ class TestMirroredComponent: # Test key property assert mirrored_component.key == "mirrored" - # Test with_key - with_key = mirrored_component.with_key("new_key") + # Test model_copy with key + with_key = mirrored_component.model_copy(key="new_key") assert with_key.key == "new_key" # Test get_meta @@ -353,8 +353,8 @@ class TestEdgeCasesAndIntegration: component = FastMCPComponent(name="test", meta=complex_meta) assert component.meta == complex_meta - def test_with_key_preserves_all_attributes(self): - """Test that with_key preserves all component attributes.""" + def test_model_copy_with_key_preserves_all_attributes(self): + """Test that model_copy with key preserves all component attributes.""" component = FastMCPComponent( name="test", title="Title", @@ -363,7 +363,7 @@ class TestEdgeCasesAndIntegration: meta={"key": "value"}, enabled=False, ) - new_component = component.with_key("new_key") + new_component = component.model_copy(key="new_key") assert new_component.name == component.name assert new_component.title == component.title @@ -389,3 +389,50 @@ class TestEdgeCasesAndIntegration: assert original.name == "original" assert copy1.name == "copy1" assert copy2.name == "copy2" + + def test_model_copy_with_update_and_key(self): + """Test that model_copy works with both update dict and key parameter.""" + component = FastMCPComponent( + name="test", + title="Original Title", + description="Original Description", + tags=["tag1"], + enabled=True, + ) + + # Test with both update and key + updated_component = component.model_copy( + update={"title": "New Title", "description": "New Description"}, + key="new_key", + ) + + assert updated_component.name == "test" # Not in update, unchanged + assert updated_component.title == "New Title" # Updated + assert updated_component.description == "New Description" # Updated + assert updated_component.tags == {"tag1"} # Not in update, unchanged + assert updated_component.enabled is True # Not in update, unchanged + assert updated_component.key == "new_key" # Custom key set + + # Original should be unchanged + assert component.title == "Original Title" + assert component.description == "Original Description" + assert component.key == "test" # Uses name as key + + def test_model_copy_deep_parameter(self): + """Test that model_copy respects the deep parameter.""" + nested_dict = {"nested": {"value": 1}} + component = FastMCPComponent(name="test", meta=nested_dict) + + # Shallow copy (default) + shallow_copy = component.model_copy() + assert shallow_copy.meta is not None + assert component.meta is not None + shallow_copy.meta["nested"]["value"] = 2 + assert component.meta["nested"]["value"] == 2 # Original affected + + # Deep copy + component.meta["nested"]["value"] = 1 # Reset + deep_copy = component.model_copy(deep=True) + assert deep_copy.meta is not None + deep_copy.meta["nested"]["value"] = 3 + assert component.meta["nested"]["value"] == 1 # Original unaffected