Spaces:
Running
Running
Jeremiah Lowin commited on
Ensure resource + template names are properly prefixed when importing/mounting (#1423)
Browse files- docs/servers/composition.mdx +6 -4
- src/fastmcp/prompts/prompt_manager.py +2 -2
- src/fastmcp/resources/resource_manager.py +12 -6
- src/fastmcp/server/server.py +8 -4
- src/fastmcp/tools/tool_manager.py +3 -1
- src/fastmcp/utilities/components.py +19 -4
- tests/resources/test_resource_manager.py +8 -8
- tests/server/test_import_server.py +38 -0
- tests/server/test_mount.py +52 -0
- tests/tools/test_tool_manager.py +2 -2
- tests/utilities/test_components.py +55 -8
docs/servers/composition.mdx
CHANGED
|
@@ -83,10 +83,12 @@ When you call `await main_mcp.import_server(subserver, prefix={whatever})`:
|
|
| 83 |
|
| 84 |
1. **Tools**: All tools from `subserver` are added to `main_mcp` with names prefixed using `{prefix}_`.
|
| 85 |
- `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`.
|
| 86 |
-
2. **Resources**: All resources are added with URIs
|
| 87 |
-
- `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="data://{prefix}/info")`.
|
|
|
|
| 88 |
3. **Resource Templates**: Templates are prefixed similarly to resources.
|
| 89 |
-
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`.
|
|
|
|
| 90 |
4. **Prompts**: All prompts are added with names prefixed using `{prefix}_`.
|
| 91 |
- `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`.
|
| 92 |
|
|
@@ -196,7 +198,7 @@ When mounting is configured:
|
|
| 196 |
3. **Prefixed Access**: The parent server uses prefixes to route requests to the mounted server.
|
| 197 |
4. **Delegation**: Requests for components matching the prefix are delegated to the mounted server at runtime.
|
| 198 |
|
| 199 |
-
The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts.
|
| 200 |
|
| 201 |
<Tip>
|
| 202 |
The `prefix` parameter is optional. If omitted, components are mounted without modification.
|
|
|
|
| 83 |
|
| 84 |
1. **Tools**: All tools from `subserver` are added to `main_mcp` with names prefixed using `{prefix}_`.
|
| 85 |
- `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`.
|
| 86 |
+
2. **Resources**: All resources are added with both URIs and names prefixed.
|
| 87 |
+
- URI: `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="data://{prefix}/info")`.
|
| 88 |
+
- Name: `resource.name` becomes `"{prefix}_{resource.name}"`.
|
| 89 |
3. **Resource Templates**: Templates are prefixed similarly to resources.
|
| 90 |
+
- URI: `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`.
|
| 91 |
+
- Name: `template.name` becomes `"{prefix}_{template.name}"`.
|
| 92 |
4. **Prompts**: All prompts are added with names prefixed using `{prefix}_`.
|
| 93 |
- `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`.
|
| 94 |
|
|
|
|
| 198 |
3. **Prefixed Access**: The parent server uses prefixes to route requests to the mounted server.
|
| 199 |
4. **Delegation**: Requests for components matching the prefix are delegated to the mounted server at runtime.
|
| 200 |
|
| 201 |
+
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.
|
| 202 |
|
| 203 |
<Tip>
|
| 204 |
The `prefix` parameter is optional. If omitted, components are mounted without modification.
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -69,8 +69,8 @@ class PromptManager:
|
|
| 69 |
child_dict = {p.key: p for p in child_results}
|
| 70 |
if mounted.prefix:
|
| 71 |
for prompt in child_dict.values():
|
| 72 |
-
prefixed_prompt = prompt.
|
| 73 |
-
f"{mounted.prefix}_{prompt.key}"
|
| 74 |
)
|
| 75 |
all_prompts[prefixed_prompt.key] = prefixed_prompt
|
| 76 |
else:
|
|
|
|
| 69 |
child_dict = {p.key: p for p in child_results}
|
| 70 |
if mounted.prefix:
|
| 71 |
for prompt in child_dict.values():
|
| 72 |
+
prefixed_prompt = prompt.model_copy(
|
| 73 |
+
key=f"{mounted.prefix}_{prompt.key}"
|
| 74 |
)
|
| 75 |
all_prompts[prefixed_prompt.key] = prefixed_prompt
|
| 76 |
else:
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -101,8 +101,11 @@ class ResourceManager:
|
|
| 101 |
prefixed_uri = add_resource_prefix(
|
| 102 |
uri, mounted.prefix, mounted.resource_prefix_format
|
| 103 |
)
|
| 104 |
-
# Create a copy of the resource with the prefixed key
|
| 105 |
-
prefixed_resource = resource.
|
|
|
|
|
|
|
|
|
|
| 106 |
all_resources[prefixed_uri] = prefixed_resource
|
| 107 |
else:
|
| 108 |
all_resources.update(child_resources)
|
|
@@ -149,8 +152,11 @@ class ResourceManager:
|
|
| 149 |
prefixed_uri_template = add_resource_prefix(
|
| 150 |
uri_template, mounted.prefix, mounted.resource_prefix_format
|
| 151 |
)
|
| 152 |
-
# Create a copy of the template with the prefixed key
|
| 153 |
-
prefixed_template = template.
|
|
|
|
|
|
|
|
|
|
| 154 |
all_templates[prefixed_uri_template] = prefixed_template
|
| 155 |
else:
|
| 156 |
all_templates.update(child_dict)
|
|
@@ -273,7 +279,7 @@ class ResourceManager:
|
|
| 273 |
Args:
|
| 274 |
resource: A Resource instance to add. The resource's .key attribute
|
| 275 |
will be used as the storage key. To overwrite it, call
|
| 276 |
-
Resource.
|
| 277 |
"""
|
| 278 |
existing = self._resources.get(resource.key)
|
| 279 |
if existing:
|
|
@@ -322,7 +328,7 @@ class ResourceManager:
|
|
| 322 |
Args:
|
| 323 |
template: A ResourceTemplate instance to add. The template's .key attribute
|
| 324 |
will be used as the storage key. To overwrite it, call
|
| 325 |
-
ResourceTemplate.
|
| 326 |
|
| 327 |
Returns:
|
| 328 |
The added template. If a template with the same URI already exists,
|
|
|
|
| 101 |
prefixed_uri = add_resource_prefix(
|
| 102 |
uri, mounted.prefix, mounted.resource_prefix_format
|
| 103 |
)
|
| 104 |
+
# Create a copy of the resource with the prefixed key and name
|
| 105 |
+
prefixed_resource = resource.model_copy(
|
| 106 |
+
update={"name": f"{mounted.prefix}_{resource.name}"},
|
| 107 |
+
key=prefixed_uri,
|
| 108 |
+
)
|
| 109 |
all_resources[prefixed_uri] = prefixed_resource
|
| 110 |
else:
|
| 111 |
all_resources.update(child_resources)
|
|
|
|
| 152 |
prefixed_uri_template = add_resource_prefix(
|
| 153 |
uri_template, mounted.prefix, mounted.resource_prefix_format
|
| 154 |
)
|
| 155 |
+
# Create a copy of the template with the prefixed key and name
|
| 156 |
+
prefixed_template = template.model_copy(
|
| 157 |
+
update={"name": f"{mounted.prefix}_{template.name}"},
|
| 158 |
+
key=prefixed_uri_template,
|
| 159 |
+
)
|
| 160 |
all_templates[prefixed_uri_template] = prefixed_template
|
| 161 |
else:
|
| 162 |
all_templates.update(child_dict)
|
|
|
|
| 279 |
Args:
|
| 280 |
resource: A Resource instance to add. The resource's .key attribute
|
| 281 |
will be used as the storage key. To overwrite it, call
|
| 282 |
+
Resource.model_copy(key=new_key) before calling this method.
|
| 283 |
"""
|
| 284 |
existing = self._resources.get(resource.key)
|
| 285 |
if existing:
|
|
|
|
| 328 |
Args:
|
| 329 |
template: A ResourceTemplate instance to add. The template's .key attribute
|
| 330 |
will be used as the storage key. To overwrite it, call
|
| 331 |
+
ResourceTemplate.model_copy(key=new_key) before calling this method.
|
| 332 |
|
| 333 |
Returns:
|
| 334 |
The added template. If a template with the same URI already exists,
|
src/fastmcp/server/server.py
CHANGED
|
@@ -1891,7 +1891,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1891 |
# Import tools from the server
|
| 1892 |
for key, tool in (await server.get_tools()).items():
|
| 1893 |
if prefix:
|
| 1894 |
-
tool = tool.
|
| 1895 |
self._tool_manager.add_tool(tool)
|
| 1896 |
|
| 1897 |
# Import resources and templates from the server
|
|
@@ -1900,7 +1900,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1900 |
resource_key = add_resource_prefix(
|
| 1901 |
key, prefix, self.resource_prefix_format
|
| 1902 |
)
|
| 1903 |
-
resource = resource.
|
|
|
|
|
|
|
| 1904 |
self._resource_manager.add_resource(resource)
|
| 1905 |
|
| 1906 |
for key, template in (await server.get_resource_templates()).items():
|
|
@@ -1908,13 +1910,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1908 |
template_key = add_resource_prefix(
|
| 1909 |
key, prefix, self.resource_prefix_format
|
| 1910 |
)
|
| 1911 |
-
template = template.
|
|
|
|
|
|
|
| 1912 |
self._resource_manager.add_template(template)
|
| 1913 |
|
| 1914 |
# Import prompts from the server
|
| 1915 |
for key, prompt in (await server.get_prompts()).items():
|
| 1916 |
if prefix:
|
| 1917 |
-
prompt = prompt.
|
| 1918 |
self._prompt_manager.add_prompt(prompt)
|
| 1919 |
|
| 1920 |
if prefix:
|
|
|
|
| 1891 |
# Import tools from the server
|
| 1892 |
for key, tool in (await server.get_tools()).items():
|
| 1893 |
if prefix:
|
| 1894 |
+
tool = tool.model_copy(key=f"{prefix}_{key}")
|
| 1895 |
self._tool_manager.add_tool(tool)
|
| 1896 |
|
| 1897 |
# Import resources and templates from the server
|
|
|
|
| 1900 |
resource_key = add_resource_prefix(
|
| 1901 |
key, prefix, self.resource_prefix_format
|
| 1902 |
)
|
| 1903 |
+
resource = resource.model_copy(
|
| 1904 |
+
update={"name": f"{prefix}_{resource.name}"}, key=resource_key
|
| 1905 |
+
)
|
| 1906 |
self._resource_manager.add_resource(resource)
|
| 1907 |
|
| 1908 |
for key, template in (await server.get_resource_templates()).items():
|
|
|
|
| 1910 |
template_key = add_resource_prefix(
|
| 1911 |
key, prefix, self.resource_prefix_format
|
| 1912 |
)
|
| 1913 |
+
template = template.model_copy(
|
| 1914 |
+
update={"name": f"{prefix}_{template.name}"}, key=template_key
|
| 1915 |
+
)
|
| 1916 |
self._resource_manager.add_template(template)
|
| 1917 |
|
| 1918 |
# Import prompts from the server
|
| 1919 |
for key, prompt in (await server.get_prompts()).items():
|
| 1920 |
if prefix:
|
| 1921 |
+
prompt = prompt.model_copy(key=f"{prefix}_{key}")
|
| 1922 |
self._prompt_manager.add_prompt(prompt)
|
| 1923 |
|
| 1924 |
if prefix:
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -75,7 +75,9 @@ class ToolManager:
|
|
| 75 |
child_dict = {t.key: t for t in child_results}
|
| 76 |
if mounted.prefix:
|
| 77 |
for tool in child_dict.values():
|
| 78 |
-
prefixed_tool = tool.
|
|
|
|
|
|
|
| 79 |
all_tools[prefixed_tool.key] = prefixed_tool
|
| 80 |
else:
|
| 81 |
all_tools.update(child_dict)
|
|
|
|
| 75 |
child_dict = {t.key: t for t in child_results}
|
| 76 |
if mounted.prefix:
|
| 77 |
for tool in child_dict.values():
|
| 78 |
+
prefixed_tool = tool.model_copy(
|
| 79 |
+
key=f"{mounted.prefix}_{tool.key}"
|
| 80 |
+
)
|
| 81 |
all_tools[prefixed_tool.key] = prefixed_tool
|
| 82 |
else:
|
| 83 |
all_tools.update(child_dict)
|
src/fastmcp/utilities/components.py
CHANGED
|
@@ -91,12 +91,27 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
| 91 |
|
| 92 |
return meta or None
|
| 93 |
|
| 94 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
# `model_copy` has an `update` parameter but it doesn't work for certain private attributes
|
| 96 |
# https://github.com/pydantic/pydantic/issues/12116
|
| 97 |
-
# So we manually set the private attribute here instead
|
| 98 |
-
copy =
|
| 99 |
-
|
|
|
|
| 100 |
return copy
|
| 101 |
|
| 102 |
def __eq__(self, other: object) -> bool:
|
|
|
|
| 91 |
|
| 92 |
return meta or None
|
| 93 |
|
| 94 |
+
def model_copy(
|
| 95 |
+
self,
|
| 96 |
+
*,
|
| 97 |
+
update: dict[str, Any] | None = None,
|
| 98 |
+
deep: bool = False,
|
| 99 |
+
key: str | None = None,
|
| 100 |
+
) -> Self:
|
| 101 |
+
"""
|
| 102 |
+
Create a copy of the component.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
update: A dictionary of fields to update.
|
| 106 |
+
deep: Whether to deep copy the component.
|
| 107 |
+
key: The key to use for the copy.
|
| 108 |
+
"""
|
| 109 |
# `model_copy` has an `update` parameter but it doesn't work for certain private attributes
|
| 110 |
# https://github.com/pydantic/pydantic/issues/12116
|
| 111 |
+
# So we manually set the private attribute here instead, such as _key
|
| 112 |
+
copy = super().model_copy(update=update, deep=deep)
|
| 113 |
+
if key is not None:
|
| 114 |
+
copy._key = key
|
| 115 |
return copy
|
| 116 |
|
| 117 |
def __eq__(self, other: object) -> bool:
|
tests/resources/test_resource_manager.py
CHANGED
|
@@ -469,8 +469,8 @@ class TestCustomResourceKeys:
|
|
| 469 |
fn=get_data,
|
| 470 |
)
|
| 471 |
|
| 472 |
-
# Use
|
| 473 |
-
resource_with_custom_key = resource.
|
| 474 |
manager.add_resource(resource_with_custom_key)
|
| 475 |
|
| 476 |
# Resource should be accessible via custom key
|
|
@@ -496,8 +496,8 @@ class TestCustomResourceKeys:
|
|
| 496 |
name="test_template",
|
| 497 |
)
|
| 498 |
|
| 499 |
-
# Use
|
| 500 |
-
template_with_custom_key = template.
|
| 501 |
manager.add_template(template_with_custom_key)
|
| 502 |
|
| 503 |
# Template should be accessible via custom key
|
|
@@ -523,8 +523,8 @@ class TestCustomResourceKeys:
|
|
| 523 |
fn=get_data,
|
| 524 |
)
|
| 525 |
|
| 526 |
-
# Use
|
| 527 |
-
resource_with_custom_key = resource.
|
| 528 |
manager.add_resource(resource_with_custom_key)
|
| 529 |
|
| 530 |
# Should be retrievable by the custom key
|
|
@@ -552,8 +552,8 @@ class TestCustomResourceKeys:
|
|
| 552 |
name="custom_greeter",
|
| 553 |
)
|
| 554 |
|
| 555 |
-
# Use
|
| 556 |
-
template_with_custom_key = template.
|
| 557 |
manager.add_template(template_with_custom_key)
|
| 558 |
|
| 559 |
# Using a URI that matches the custom key pattern
|
|
|
|
| 469 |
fn=get_data,
|
| 470 |
)
|
| 471 |
|
| 472 |
+
# Use model_copy to create a new resource with the custom key
|
| 473 |
+
resource_with_custom_key = resource.model_copy(key=custom_key)
|
| 474 |
manager.add_resource(resource_with_custom_key)
|
| 475 |
|
| 476 |
# Resource should be accessible via custom key
|
|
|
|
| 496 |
name="test_template",
|
| 497 |
)
|
| 498 |
|
| 499 |
+
# Use model_copy to create a new template with the custom key
|
| 500 |
+
template_with_custom_key = template.model_copy(key=custom_key)
|
| 501 |
manager.add_template(template_with_custom_key)
|
| 502 |
|
| 503 |
# Template should be accessible via custom key
|
|
|
|
| 523 |
fn=get_data,
|
| 524 |
)
|
| 525 |
|
| 526 |
+
# Use model_copy to create a new resource with the custom key
|
| 527 |
+
resource_with_custom_key = resource.model_copy(key=custom_key)
|
| 528 |
manager.add_resource(resource_with_custom_key)
|
| 529 |
|
| 530 |
# Should be retrievable by the custom key
|
|
|
|
| 552 |
name="custom_greeter",
|
| 553 |
)
|
| 554 |
|
| 555 |
+
# Use model_copy to create a new template with the custom key
|
| 556 |
+
template_with_custom_key = template.model_copy(key=custom_key)
|
| 557 |
manager.add_template(template_with_custom_key)
|
| 558 |
|
| 559 |
# Using a URI that matches the custom key pattern
|
tests/server/test_import_server.py
CHANGED
|
@@ -605,3 +605,41 @@ async def test_import_conflict_resolution_with_prefix():
|
|
| 605 |
|
| 606 |
result = await client.call_tool("api_shared_tool", {})
|
| 607 |
assert result.data == "Second app tool"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
|
| 606 |
result = await client.call_tool("api_shared_tool", {})
|
| 607 |
assert result.data == "Second app tool"
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
async def test_import_server_resource_name_prefixing():
|
| 611 |
+
"""Test that resource names are prefixed when using import_server."""
|
| 612 |
+
# Create a sub-server with a resource
|
| 613 |
+
sub_server = FastMCP("SubServer")
|
| 614 |
+
|
| 615 |
+
@sub_server.resource("resource://test_resource")
|
| 616 |
+
def test_resource() -> str:
|
| 617 |
+
return "Test content"
|
| 618 |
+
|
| 619 |
+
# Create main server and import sub-server with prefix
|
| 620 |
+
main_server = FastMCP("MainServer")
|
| 621 |
+
await main_server.import_server(sub_server, prefix="imported")
|
| 622 |
+
|
| 623 |
+
# Get resources and verify name prefixing
|
| 624 |
+
resources = await main_server.get_resources()
|
| 625 |
+
resource = resources["resource://imported/test_resource"]
|
| 626 |
+
assert resource.name == "imported_test_resource"
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
async def test_import_server_resource_template_name_prefixing():
|
| 630 |
+
"""Test that resource template names are prefixed when using import_server."""
|
| 631 |
+
# Create a sub-server with a resource template
|
| 632 |
+
sub_server = FastMCP("SubServer")
|
| 633 |
+
|
| 634 |
+
@sub_server.resource("resource://data/{item_id}")
|
| 635 |
+
def data_template(item_id: str) -> str:
|
| 636 |
+
return f"Data for {item_id}"
|
| 637 |
+
|
| 638 |
+
# Create main server and import sub-server with prefix
|
| 639 |
+
main_server = FastMCP("MainServer")
|
| 640 |
+
await main_server.import_server(sub_server, prefix="imported")
|
| 641 |
+
|
| 642 |
+
# Get resource templates and verify name prefixing
|
| 643 |
+
templates = await main_server.get_resource_templates()
|
| 644 |
+
template = templates["resource://imported/data/{item_id}"]
|
| 645 |
+
assert template.name == "imported_data_template"
|
tests/server/test_mount.py
CHANGED
|
@@ -967,3 +967,55 @@ class TestAsProxyKwarg:
|
|
| 967 |
# in the present implementation the sub server will be invoked 3 times
|
| 968 |
# to call its tool
|
| 969 |
assert lifespan_check.count("start") >= 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
# in the present implementation the sub server will be invoked 3 times
|
| 968 |
# to call its tool
|
| 969 |
assert lifespan_check.count("start") >= 2
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
class TestResourceNamePrefixing:
|
| 973 |
+
"""Test that resource and resource template names get prefixed when mounted."""
|
| 974 |
+
|
| 975 |
+
async def test_resource_name_prefixing(self):
|
| 976 |
+
"""Test that resource names are prefixed when mounted."""
|
| 977 |
+
|
| 978 |
+
# Create a sub-app with a resource
|
| 979 |
+
sub_app = FastMCP("SubApp")
|
| 980 |
+
|
| 981 |
+
@sub_app.resource("resource://my_resource")
|
| 982 |
+
def my_resource() -> str:
|
| 983 |
+
return "Resource content"
|
| 984 |
+
|
| 985 |
+
# Create main app and mount sub-app with prefix
|
| 986 |
+
main_app = FastMCP("MainApp")
|
| 987 |
+
main_app.mount(sub_app, "prefix")
|
| 988 |
+
|
| 989 |
+
# Get resources from main app
|
| 990 |
+
resources = await main_app.get_resources()
|
| 991 |
+
|
| 992 |
+
# Should have prefixed key (using path format: resource://prefix/resource_name)
|
| 993 |
+
assert "resource://prefix/my_resource" in resources
|
| 994 |
+
|
| 995 |
+
# The resource name should also be prefixed
|
| 996 |
+
resource = resources["resource://prefix/my_resource"]
|
| 997 |
+
assert resource.name == "prefix_my_resource"
|
| 998 |
+
|
| 999 |
+
async def test_resource_template_name_prefixing(self):
|
| 1000 |
+
"""Test that resource template names are prefixed when mounted."""
|
| 1001 |
+
|
| 1002 |
+
# Create a sub-app with a resource template
|
| 1003 |
+
sub_app = FastMCP("SubApp")
|
| 1004 |
+
|
| 1005 |
+
@sub_app.resource("resource://user/{user_id}")
|
| 1006 |
+
def user_template(user_id: str) -> str:
|
| 1007 |
+
return f"User {user_id} data"
|
| 1008 |
+
|
| 1009 |
+
# Create main app and mount sub-app with prefix
|
| 1010 |
+
main_app = FastMCP("MainApp")
|
| 1011 |
+
main_app.mount(sub_app, "prefix")
|
| 1012 |
+
|
| 1013 |
+
# Get resource templates from main app
|
| 1014 |
+
templates = await main_app.get_resource_templates()
|
| 1015 |
+
|
| 1016 |
+
# Should have prefixed key (using path format: resource://prefix/template_uri)
|
| 1017 |
+
assert "resource://prefix/user/{user_id}" in templates
|
| 1018 |
+
|
| 1019 |
+
# The template name should also be prefixed
|
| 1020 |
+
template = templates["resource://prefix/user/{user_id}"]
|
| 1021 |
+
assert template.name == "prefix_user_template"
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -834,8 +834,8 @@ class TestCustomToolNames:
|
|
| 834 |
# Create a tool with a specific name
|
| 835 |
tool = Tool.from_function(fn, name="my_tool")
|
| 836 |
manager = ToolManager()
|
| 837 |
-
# Use
|
| 838 |
-
tool_with_custom_key = tool.
|
| 839 |
manager.add_tool(tool_with_custom_key)
|
| 840 |
# The tool is accessible under the key
|
| 841 |
stored = await manager.get_tool("proxy_tool")
|
|
|
|
| 834 |
# Create a tool with a specific name
|
| 835 |
tool = Tool.from_function(fn, name="my_tool")
|
| 836 |
manager = ToolManager()
|
| 837 |
+
# Use model_copy to create a new tool with the custom key
|
| 838 |
+
tool_with_custom_key = tool.model_copy(key="proxy_tool")
|
| 839 |
manager.add_tool(tool_with_custom_key)
|
| 840 |
# The tool is accessible under the key
|
| 841 |
stored = await manager.get_tool("proxy_tool")
|
tests/utilities/test_components.py
CHANGED
|
@@ -123,9 +123,9 @@ class TestFastMCPComponent:
|
|
| 123 |
result = component.get_meta(include_fastmcp_meta=False)
|
| 124 |
assert result is None
|
| 125 |
|
| 126 |
-
def
|
| 127 |
-
"""Test that
|
| 128 |
-
new_component = basic_component.
|
| 129 |
assert new_component.key == "new_key"
|
| 130 |
assert new_component.name == basic_component.name
|
| 131 |
assert new_component is not basic_component # Should be a copy
|
|
@@ -290,8 +290,8 @@ class TestMirroredComponent:
|
|
| 290 |
# Test key property
|
| 291 |
assert mirrored_component.key == "mirrored"
|
| 292 |
|
| 293 |
-
# Test
|
| 294 |
-
with_key = mirrored_component.
|
| 295 |
assert with_key.key == "new_key"
|
| 296 |
|
| 297 |
# Test get_meta
|
|
@@ -353,8 +353,8 @@ class TestEdgeCasesAndIntegration:
|
|
| 353 |
component = FastMCPComponent(name="test", meta=complex_meta)
|
| 354 |
assert component.meta == complex_meta
|
| 355 |
|
| 356 |
-
def
|
| 357 |
-
"""Test that
|
| 358 |
component = FastMCPComponent(
|
| 359 |
name="test",
|
| 360 |
title="Title",
|
|
@@ -363,7 +363,7 @@ class TestEdgeCasesAndIntegration:
|
|
| 363 |
meta={"key": "value"},
|
| 364 |
enabled=False,
|
| 365 |
)
|
| 366 |
-
new_component = component.
|
| 367 |
|
| 368 |
assert new_component.name == component.name
|
| 369 |
assert new_component.title == component.title
|
|
@@ -389,3 +389,50 @@ class TestEdgeCasesAndIntegration:
|
|
| 389 |
assert original.name == "original"
|
| 390 |
assert copy1.name == "copy1"
|
| 391 |
assert copy2.name == "copy2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
result = component.get_meta(include_fastmcp_meta=False)
|
| 124 |
assert result is None
|
| 125 |
|
| 126 |
+
def test_model_copy_creates_copy_with_new_key(self, basic_component):
|
| 127 |
+
"""Test that model_copy with key creates a copy with a new key."""
|
| 128 |
+
new_component = basic_component.model_copy(key="new_key")
|
| 129 |
assert new_component.key == "new_key"
|
| 130 |
assert new_component.name == basic_component.name
|
| 131 |
assert new_component is not basic_component # Should be a copy
|
|
|
|
| 290 |
# Test key property
|
| 291 |
assert mirrored_component.key == "mirrored"
|
| 292 |
|
| 293 |
+
# Test model_copy with key
|
| 294 |
+
with_key = mirrored_component.model_copy(key="new_key")
|
| 295 |
assert with_key.key == "new_key"
|
| 296 |
|
| 297 |
# Test get_meta
|
|
|
|
| 353 |
component = FastMCPComponent(name="test", meta=complex_meta)
|
| 354 |
assert component.meta == complex_meta
|
| 355 |
|
| 356 |
+
def test_model_copy_with_key_preserves_all_attributes(self):
|
| 357 |
+
"""Test that model_copy with key preserves all component attributes."""
|
| 358 |
component = FastMCPComponent(
|
| 359 |
name="test",
|
| 360 |
title="Title",
|
|
|
|
| 363 |
meta={"key": "value"},
|
| 364 |
enabled=False,
|
| 365 |
)
|
| 366 |
+
new_component = component.model_copy(key="new_key")
|
| 367 |
|
| 368 |
assert new_component.name == component.name
|
| 369 |
assert new_component.title == component.title
|
|
|
|
| 389 |
assert original.name == "original"
|
| 390 |
assert copy1.name == "copy1"
|
| 391 |
assert copy2.name == "copy2"
|
| 392 |
+
|
| 393 |
+
def test_model_copy_with_update_and_key(self):
|
| 394 |
+
"""Test that model_copy works with both update dict and key parameter."""
|
| 395 |
+
component = FastMCPComponent(
|
| 396 |
+
name="test",
|
| 397 |
+
title="Original Title",
|
| 398 |
+
description="Original Description",
|
| 399 |
+
tags=["tag1"],
|
| 400 |
+
enabled=True,
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
# Test with both update and key
|
| 404 |
+
updated_component = component.model_copy(
|
| 405 |
+
update={"title": "New Title", "description": "New Description"},
|
| 406 |
+
key="new_key",
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
assert updated_component.name == "test" # Not in update, unchanged
|
| 410 |
+
assert updated_component.title == "New Title" # Updated
|
| 411 |
+
assert updated_component.description == "New Description" # Updated
|
| 412 |
+
assert updated_component.tags == {"tag1"} # Not in update, unchanged
|
| 413 |
+
assert updated_component.enabled is True # Not in update, unchanged
|
| 414 |
+
assert updated_component.key == "new_key" # Custom key set
|
| 415 |
+
|
| 416 |
+
# Original should be unchanged
|
| 417 |
+
assert component.title == "Original Title"
|
| 418 |
+
assert component.description == "Original Description"
|
| 419 |
+
assert component.key == "test" # Uses name as key
|
| 420 |
+
|
| 421 |
+
def test_model_copy_deep_parameter(self):
|
| 422 |
+
"""Test that model_copy respects the deep parameter."""
|
| 423 |
+
nested_dict = {"nested": {"value": 1}}
|
| 424 |
+
component = FastMCPComponent(name="test", meta=nested_dict)
|
| 425 |
+
|
| 426 |
+
# Shallow copy (default)
|
| 427 |
+
shallow_copy = component.model_copy()
|
| 428 |
+
assert shallow_copy.meta is not None
|
| 429 |
+
assert component.meta is not None
|
| 430 |
+
shallow_copy.meta["nested"]["value"] = 2
|
| 431 |
+
assert component.meta["nested"]["value"] == 2 # Original affected
|
| 432 |
+
|
| 433 |
+
# Deep copy
|
| 434 |
+
component.meta["nested"]["value"] = 1 # Reset
|
| 435 |
+
deep_copy = component.model_copy(deep=True)
|
| 436 |
+
assert deep_copy.meta is not None
|
| 437 |
+
deep_copy.meta["nested"]["value"] = 3
|
| 438 |
+
assert component.meta["nested"]["value"] == 1 # Original unaffected
|