Ensure resource + template names are properly prefixed when importing/mounting (#1423)

This commit is contained in:
Jeremiah Lowin 2025-08-08 21:29:07 -04:00 committed by GitHub
commit 19a4dd85d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 205 additions and 39 deletions

View file

@ -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.
<Tip>
The `prefix` parameter is optional. If omitted, components are mounted without modification.

View file

@ -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:

View file

@ -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,

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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")

View file

@ -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