mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Ensure resource + template names are properly prefixed when importing/mounting (#1423)
This commit is contained in:
parent
22de076b75
commit
19a4dd85d4
11 changed files with 205 additions and 39 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue