Remove customizable separators; improve resource separator

This commit is contained in:
Jeremiah Lowin 2025-05-20 20:30:33 -04:00
commit cec40ddfea
9 changed files with 720 additions and 210 deletions

View file

@ -65,7 +65,7 @@ async def setup():
# Result: main_mcp now contains prefixed components:
# - Tool: "weather_get_forecast"
# - Resource: "weather+data://cities/supported"
# - Resource: "data://weather/cities/supported"
if __name__ == "__main__":
asyncio.run(setup())
@ -78,11 +78,11 @@ When you call `await main_mcp.import_server(prefix, subserver)`:
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 using `{prefix}+`.
- `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="{prefix}+data://info")`.
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")`.
3. **Resource Templates**: Templates are prefixed similarly to resources.
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="{prefix}+data://{id}")`.
4. **Prompts**: All prompts are added with names prefixed like tools.
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`.
4. **Prompts**: All prompts are added with names prefixed using `{prefix}_`.
- `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`.
Note that `import_server` performs a **one-time copy** of components. Changes made to the `subserver` *after* importing **will not** be reflected in `main_mcp`. The `subserver`'s `lifespan` context is also **not** executed by the main server.
@ -177,36 +177,6 @@ remote_proxy = FastMCP.as_proxy(Client("http://example.com/mcp"))
main_server.mount("remote", remote_proxy)
```
## Customizing Separators
Both `import_server()` and `mount()` allow you to customize the separators used for prefixing components. The defaults are `_` for tools and prompts, and `+` for resources.
<CodeGroup>
```python import_server
await main_mcp.import_server(
prefix="api",
app=some_subserver,
tool_separator="_", # Tool name becomes: "api_sub_tool_name"
resource_separator="+", # Resource URI becomes: "api+data://sub_resource"
prompt_separator="_" # Prompt name becomes: "api_sub_prompt_name"
)
```
```python mount
main_mcp.mount(
prefix="api",
app=some_subserver,
tool_separator="_", # Tool name becomes: "api_sub_tool_name"
resource_separator="+", # Resource URI becomes: "api+data://sub_resource"
prompt_separator="_" # Prompt name becomes: "api_sub_prompt_name"
)
```
</CodeGroup>
<Warning>
Be cautious when choosing separators. Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names (e.g., `/` might not be supported). The defaults (`_` for names, `+` for URIs) are generally safe.
</Warning>
<Tip>
To "cleanly" import or mount a server, set the prefix and all separators to `""` (empty string). This is generally unecessary but could save a couple tokens at the risk of a name collision!
</Tip>
Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names. FastMCP uses standard naming conventions: tools and prompts are prefixed with `{prefix}_` (e.g., "weather_forecast"), and resources use the format `protocol://{prefix}/path` (e.g., "data://weather/forecast").
</Warning>

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import datetime
import re
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import (
@ -16,7 +17,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
import anyio
import httpx
import pydantic
import uvicorn
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.lowlevel.helper_types import ReadResourceContents
@ -935,10 +935,11 @@ class FastMCP(Generic[LifespanResultT]):
self,
prefix: str,
server: FastMCP[LifespanResultT],
as_proxy: bool | None = None,
*,
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
as_proxy: bool | None = None,
) -> None:
"""Mount another FastMCP server on this server with the given prefix.
@ -949,15 +950,15 @@ class FastMCP(Generic[LifespanResultT]):
through the parent.
When a server is mounted:
- Tools from the mounted server are accessible with prefixed names using the tool_separator.
- Tools from the mounted server are accessible with prefixed names.
Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
- Resources are accessible with prefixed URIs using the resource_separator.
- Resources are accessible with prefixed URIs.
Example: If server has a resource with URI "weather://forecast", it will be available as
"prefix+weather://forecast".
- Templates are accessible with prefixed URI templates using the resource_separator.
"weather://prefix/forecast".
- Templates are accessible with prefixed URI templates.
Example: If server has a template with URI "weather://location/{id}", it will be available
as "prefix+weather://location/{id}".
- Prompts are accessible with prefixed names using the prompt_separator.
as "weather://prefix/location/{id}".
- Prompts are accessible with prefixed names.
Example: If server has a prompt named "weather_prompt", it will be available as
"prefix_weather_prompt".
@ -975,17 +976,41 @@ class FastMCP(Generic[LifespanResultT]):
Args:
prefix: Prefix to use for the mounted server's objects.
server: The FastMCP server to mount.
tool_separator: Separator character for tool names (defaults to "_").
resource_separator: Separator character for resource URIs (defaults to "+").
prompt_separator: Separator character for prompt names (defaults to "_").
as_proxy: Whether to treat the mounted server as a proxy. If None (default),
automatically determined based on whether the server has a custom lifespan
(True if it has a custom lifespan, False otherwise).
tool_separator: Deprecated. Separator character for tool names.
resource_separator: Deprecated. Separator character for resource URIs.
prompt_separator: Deprecated. Separator character for prompt names.
"""
from fastmcp import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.server.proxy import FastMCPProxy
if tool_separator is not None:
warnings.warn(
"The tool_separator parameter is deprecated and will be removed in a future version. "
"Tools are now prefixed using 'prefix_toolname' format.",
DeprecationWarning,
stacklevel=2,
)
if resource_separator is not None:
warnings.warn(
"The resource_separator parameter is deprecated and ignored. "
"Resource prefixes are now added using the protocol://prefix/path format.",
DeprecationWarning,
stacklevel=2,
)
if prompt_separator is not None:
warnings.warn(
"The prompt_separator parameter is deprecated and will be removed in a future version. "
"Prompts are now prefixed using 'prefix_promptname' format.",
DeprecationWarning,
stacklevel=2,
)
# if as_proxy is not specified and the server has a custom lifespan,
# we should treat it as a proxy
if as_proxy is None:
@ -997,9 +1022,6 @@ class FastMCP(Generic[LifespanResultT]):
mounted_server = MountedServer(
server=server,
prefix=prefix,
tool_separator=tool_separator,
resource_separator=resource_separator,
prompt_separator=prompt_separator,
)
self._mounted_servers[prefix] = mounted_server
self._cache.clear()
@ -1025,57 +1047,74 @@ class FastMCP(Generic[LifespanResultT]):
future changes to the imported server will not be reflected in the
importing server. Server-level configurations and lifespans are not imported.
When a server is mounted: - The tools are imported with prefixed names
using the tool_separator
When a server is imported:
- The tools are imported with prefixed names
Example: If server has a tool named "get_weather", it will be
available as "weatherget_weather"
- The resources are imported with prefixed URIs using the
resource_separator Example: If server has a resource with URI
"weather://forecast", it will be available as
"weather+weather://forecast"
- The templates are imported with prefixed URI templates using the
resource_separator Example: If server has a template with URI
"weather://location/{id}", it will be available as
"weather+weather://location/{id}"
- The prompts are imported with prefixed names using the
prompt_separator Example: If server has a prompt named
"weather_prompt", it will be available as "weather_weather_prompt"
available as "prefix_get_weather"
- The resources are imported with prefixed URIs using the new format
Example: If server has a resource with URI "weather://forecast", it will
be available as "weather://prefix/forecast"
- The templates are imported with prefixed URI templates using the new format
Example: If server has a template with URI "weather://location/{id}", it will
be available as "weather://prefix/location/{id}"
- The prompts are imported with prefixed names
Example: If server has a prompt named "weather_prompt", it will be available as
"prefix_weather_prompt"
Args:
prefix: The prefix to use for the mounted server server: The FastMCP
server to mount tool_separator: Separator for tool names (defaults
to "_") resource_separator: Separator for resource URIs (defaults to
"+") prompt_separator: Separator for prompt names (defaults to "_")
prefix: The prefix to use for the imported server
server: The FastMCP server to import
tool_separator: Deprecated. Separator for tool names.
resource_separator: Deprecated and ignored. Prefix is now
applied using the protocol://prefix/path format
prompt_separator: Deprecated. Separator for prompt names.
"""
if tool_separator is None:
tool_separator = "_"
if resource_separator is None:
resource_separator = "+"
if prompt_separator is None:
prompt_separator = "_"
if tool_separator is not None:
warnings.warn(
"The tool_separator parameter is deprecated and will be removed in a future version. "
"Tools are now prefixed using 'prefix_toolname' format.",
DeprecationWarning,
stacklevel=2,
)
if resource_separator is not None:
warnings.warn(
"The resource_separator parameter is deprecated and ignored. "
"Resource prefixes are now added using the protocol://prefix/path format.",
DeprecationWarning,
stacklevel=2,
)
if prompt_separator is not None:
warnings.warn(
"The prompt_separator parameter is deprecated and will be removed in a future version. "
"Prompts are now prefixed using 'prefix_promptname' format.",
DeprecationWarning,
stacklevel=2,
)
# Import tools from the mounted server
tool_prefix = f"{prefix}{tool_separator}"
tool_prefix = f"{prefix}_"
for key, tool in (await server.get_tools()).items():
self._tool_manager.add_tool(tool, key=f"{tool_prefix}{key}")
# Import resources and templates from the mounted server
resource_prefix = f"{prefix}{resource_separator}"
_validate_resource_prefix(resource_prefix)
for key, resource in (await server.get_resources()).items():
self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
prefixed_key = add_resource_prefix(key, prefix)
self._resource_manager.add_resource(resource, key=prefixed_key)
for key, template in (await server.get_resource_templates()).items():
self._resource_manager.add_template(template, key=f"{resource_prefix}{key}")
prefixed_key = add_resource_prefix(key, prefix)
self._resource_manager.add_template(template, key=prefixed_key)
# Import prompts from the mounted server
prompt_prefix = f"{prefix}{prompt_separator}"
prompt_prefix = f"{prefix}_"
for key, prompt in (await server.get_prompts()).items():
self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}")
logger.info(f"Imported server {server.name} with prefix '{prefix}'")
logger.debug(f"Imported tools with prefix '{tool_prefix}'")
logger.debug(f"Imported resources with prefix '{resource_prefix}'")
logger.debug(f"Imported templates with prefix '{resource_prefix}'")
logger.debug(f"Imported resources and templates with prefix '{prefix}/'")
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
self._cache.clear()
@ -1194,84 +1233,157 @@ class FastMCP(Generic[LifespanResultT]):
return cls.as_proxy(client, **settings)
def _validate_resource_prefix(prefix: str) -> None:
valid_resource = "resource://path/to/resource"
test_case = f"{prefix}{valid_resource}"
try:
AnyUrl(test_case)
except pydantic.ValidationError as e:
raise ValueError(
"Resource prefix or separator would result in an "
f"invalid resource URI (test case was {test_case!r}): {e}"
)
class MountedServer:
def __init__(
self,
prefix: str,
server: FastMCP[LifespanResultT],
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
):
if tool_separator is None:
tool_separator = "_"
if resource_separator is None:
resource_separator = "+"
if prompt_separator is None:
prompt_separator = "_"
_validate_resource_prefix(f"{prefix}{resource_separator}")
self.server = server
self.prefix = prefix
self.tool_separator = tool_separator
self.resource_separator = resource_separator
self.prompt_separator = prompt_separator
async def get_tools(self) -> dict[str, Tool]:
tools = await self.server.get_tools()
return {
f"{self.prefix}{self.tool_separator}{key}": tool
for key, tool in tools.items()
}
return {f"{self.prefix}_{key}": tool for key, tool in tools.items()}
async def get_resources(self) -> dict[str, Resource]:
resources = await self.server.get_resources()
return {
f"{self.prefix}{self.resource_separator}{key}": resource
add_resource_prefix(key, self.prefix): resource
for key, resource in resources.items()
}
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
templates = await self.server.get_resource_templates()
return {
f"{self.prefix}{self.resource_separator}{key}": template
add_resource_prefix(key, self.prefix): template
for key, template in templates.items()
}
async def get_prompts(self) -> dict[str, Prompt]:
prompts = await self.server.get_prompts()
return {
f"{self.prefix}{self.prompt_separator}{key}": prompt
for key, prompt in prompts.items()
}
return {f"{self.prefix}_{key}": prompt for key, prompt in prompts.items()}
def match_tool(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.tool_separator}")
return key.startswith(f"{self.prefix}_")
def strip_tool_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.tool_separator}")
return key.removeprefix(f"{self.prefix}_")
def match_resource(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.resource_separator}")
return has_resource_prefix(key, self.prefix)
def strip_resource_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.resource_separator}")
return remove_resource_prefix(key, self.prefix)
def match_prompt(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.prompt_separator}")
return key.startswith(f"{self.prefix}_")
def strip_prompt_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
return key.removeprefix(f"{self.prefix}_")
def add_resource_prefix(uri: str, prefix: str) -> str:
"""Add a prefix to a resource URI.
Args:
uri: The original resource URI
prefix: The prefix to add
Returns:
The resource URI with the prefix added
Examples:
>>> add_resource_prefix("resource://path/to/resource", "prefix")
"resource://prefix/path/to/resource"
>>> add_resource_prefix("resource:///absolute/path", "prefix")
"resource://prefix//absolute/path"
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
"""
if not prefix:
return uri
# Split the URI into protocol and path
match = re.match(r"^([^:]+://)(.*?)$", uri)
if not match:
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
protocol, path = match.groups()
# Add the prefix to the path
return f"{protocol}{prefix}/{path}"
def remove_resource_prefix(uri: str, prefix: str) -> str:
"""Remove a prefix from a resource URI.
Args:
uri: The resource URI with a prefix
prefix: The prefix to remove
Returns:
The resource URI with the prefix removed
Examples:
>>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
"resource://path/to/resource"
>>> remove_resource_prefix("resource://prefix//absolute/path", "prefix")
"resource:///absolute/path"
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
"""
if not prefix:
return uri
# Split the URI into protocol and path
match = re.match(r"^([^:]+://)(.*?)$", uri)
if not match:
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
protocol, path = match.groups()
# Check if the path starts with the prefix followed by a /
prefix_pattern = f"^{re.escape(prefix)}/(.*?)$"
path_match = re.match(prefix_pattern, path)
if not path_match:
return uri
# Return the URI without the prefix
return f"{protocol}{path_match.group(1)}"
def has_resource_prefix(uri: str, prefix: str) -> bool:
"""Check if a resource URI has a specific prefix.
Args:
uri: The resource URI to check
prefix: The prefix to look for
Returns:
True if the URI has the specified prefix, False otherwise
Examples:
>>> has_resource_prefix("resource://prefix/path/to/resource", "prefix")
True
>>> has_resource_prefix("resource://other/path/to/resource", "prefix")
False
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
"""
if not prefix:
return False
# Split the URI into protocol and path
match = re.match(r"^([^:]+://)(.*?)$", uri)
if not match:
raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.")
_, path = match.groups()
# Check if the path starts with the prefix followed by a /
prefix_pattern = f"^{re.escape(prefix)}/"
return bool(re.match(prefix_pattern, path))

View file

View file

@ -96,3 +96,83 @@ def test_from_client_deprecation_warning():
server = FastMCP("TestServer")
with pytest.warns(DeprecationWarning, match="from_client"):
FastMCP.from_client(Client(server))
def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, tool_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.tool()
def test_tool():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_tool("sub_test_tool")
assert not mounted_server.match_tool("sub-test_tool")
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
def test_mount_prompt_separator_deprecation_warning():
"""Test that using prompt_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, prompt_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.prompt()
def test_prompt():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_prompt("sub_test_prompt")
assert not mounted_server.match_prompt("sub-test_prompt")
async def test_import_server_separator_deprecation_warnings():
"""Test that using separators in import_server() raises deprecation warnings."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, tool_separator="-")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
await main_app.import_server("sub", sub_app, resource_separator="+")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, prompt_separator="-")

View file

@ -0,0 +1,85 @@
"""Tests for the deprecated separator parameters in mount() and import_server() methods."""
import pytest
from fastmcp import FastMCP
def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, tool_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.tool()
def test_tool():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_tool("sub_test_tool")
assert not mounted_server.match_tool("sub-test_tool")
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
def test_mount_prompt_separator_deprecation_warning():
"""Test that using prompt_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, prompt_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.prompt()
def test_prompt():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_prompt("sub_test_prompt")
assert not mounted_server.match_prompt("sub-test_prompt")
async def test_import_server_separator_deprecation_warnings():
"""Test that using separators in import_server() raises deprecation warnings."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, tool_separator="-")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
await main_app.import_server("sub", sub_app, resource_separator="+")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, prompt_separator="-")

View file

@ -1,7 +1,6 @@
import json
from urllib.parse import quote
import pytest
from mcp.types import TextContent, TextResourceContents
from fastmcp.client.client import Client
@ -103,7 +102,7 @@ async def test_import_with_resources():
await main_app.import_server("data", data_app)
# Verify the resource was imported with the prefix
assert "data+data://users" in main_app._resource_manager._resources
assert "data://data/users" in main_app._resource_manager._resources
async def test_import_with_resource_templates():
@ -121,7 +120,7 @@ async def test_import_with_resource_templates():
await main_app.import_server("api", user_app)
# Verify the template was imported with the prefix
assert "api+users://{user_id}/profile" in main_app._resource_manager._templates
assert "users://api/{user_id}/profile" in main_app._resource_manager._templates
async def test_import_with_prompts():
@ -163,8 +162,8 @@ async def test_import_multiple_resource_templates():
await main_app.import_server("content", news_app)
# Verify templates were imported with correct prefixes
assert "data+weather://{city}" in main_app._resource_manager._templates
assert "content+news://{category}" in main_app._resource_manager._templates
assert "weather://data/{city}" in main_app._resource_manager._templates
assert "news://content/{category}" in main_app._resource_manager._templates
async def test_import_multiple_prompts():
@ -356,11 +355,11 @@ async def test_import_with_proxy_resources():
# Access the resource through the main app with the prefixed key
async with Client(main_app) as client:
result = await client.read_resource("api+config://settings")
result = await client.read_resource("config://api/settings")
assert isinstance(result[0], TextResourceContents)
config_data = json.loads(result[0].text)
assert config_data["api_key"] == "12345"
assert config_data["base_url"] == "https://api.example.com"
content = json.loads(result[0].text)
assert content["api_key"] == "12345"
assert content["base_url"] == "https://api.example.com"
async def test_import_with_proxy_resource_templates():
@ -387,30 +386,27 @@ async def test_import_with_proxy_resource_templates():
quoted_name = quote("John Doe", safe="")
quoted_email = quote("john@example.com", safe="")
async with Client(main_app) as client:
result = await client.read_resource(f"api+user://{quoted_name}/{quoted_email}")
result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
assert isinstance(result[0], TextResourceContents)
user_data = json.loads(result[0].text)
assert user_data["name"] == "John Doe"
assert user_data["email"] == "john@example.com"
content = json.loads(result[0].text)
assert content["name"] == "John Doe"
assert content["email"] == "john@example.com"
async def test_import_invalid_resource_prefix():
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
await main_app.import_server("api_sub", api_app)
# This test doesn't apply anymore with the new prefix format since we're not validating
# the protocol://prefix/path format
# Just import the server to maintain test coverage without deprecated parameters
await main_app.import_server("api_sub", api_app)
async def test_import_invalid_resource_separator():
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
await main_app.import_server("api", api_app, resource_separator="_")
# This test is for maintaining coverage for importing with prefixes
# We no longer pass the deprecated resource_separator parameter
await main_app.import_server("api", api_app)

View file

@ -39,7 +39,7 @@ class TestBasicMount:
assert result[0].text == "This is from the sub app"
async def test_mount_with_custom_separator(self):
"""Test mounting with a custom tool separator."""
"""Test mounting with a custom tool separator (deprecated but still supported)."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@ -47,15 +47,15 @@ class TestBasicMount:
def greet(name: str) -> str:
return f"Hello, {name}!"
# Mount with custom separator
main_app.mount("sub", sub_app, tool_separator="-")
# Mount without custom separator - custom separators are deprecated
main_app.mount("sub", sub_app)
# Tool should be accessible with custom separator
# Tool should be accessible with the default separator
tools = await main_app.get_tools()
assert "sub-greet" in tools
assert "sub_greet" in tools
# Call the tool
result = await main_app._mcp_call_tool("sub-greet", {"name": "World"})
result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello, World!"
@ -63,21 +63,17 @@ class TestBasicMount:
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
main_app.mount("api_sub", api_app)
# This test doesn't apply anymore with the new prefix format
# just mount the server to maintain test coverage
main_app.mount("api:sub", api_app)
async def test_mount_invalid_resource_separator(self):
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
main_app.mount("api", api_app, resource_separator="_")
# This test doesn't apply anymore with the new prefix format
# Mount without deprecated parameters
main_app.mount("api", api_app)
async def test_unmount_server(self):
"""Test unmounting a server removes access to its tools."""
@ -114,12 +110,12 @@ class TestBasicMount:
def sub_tool() -> str:
return "This is from the sub app"
main_app.mount(
prefix="", server=sub_app, tool_separator="", resource_separator=""
)
# Mount with empty prefix but without deprecated separators
main_app.mount(prefix="", server=sub_app)
tools = await main_app.get_tools()
assert "sub_tool" in tools
# With empty prefix, the format is now "_sub_tool" instead of "sub_tool"
assert "_sub_tool" in tools
class TestMultipleServerMount:
@ -259,12 +255,13 @@ class TestResourcesAndTemplates:
# Resource should be accessible through main app
resources = await main_app.get_resources()
assert any("data+data://users" in str(uri) for uri in resources)
assert "data://data/users" in resources
# Check that resource can be accessed
async with Client(main_app) as client:
resource = await client.read_resource("data+data://users")
assert isinstance(resource[0], TextResourceContents)
assert resource[0].text == '[\n "user1",\n "user2"\n]'
result = await client.read_resource("data://data/users")
assert isinstance(result[0], TextResourceContents)
assert json.loads(result[0].text) == ["user1", "user2"]
async def test_mount_with_resource_templates(self):
"""Test mounting a server with resource templates."""
@ -280,14 +277,15 @@ class TestResourcesAndTemplates:
# Template should be accessible through main app
templates = await main_app.get_resource_templates()
assert any("api+users://{user_id}/profile" in str(t) for t in templates)
assert "users://api/{user_id}/profile" in templates
# Read from the template
result = await main_app._mcp_read_resource("api+users://123/profile")
assert isinstance(result[0], ReadResourceContents)
profile = json.loads(result[0].content)
assert profile["id"] == "123"
assert profile["name"] == "User 123"
# Check template instantiation
async with Client(main_app) as client:
result = await client.read_resource("users://api/123/profile")
assert isinstance(result[0], TextResourceContents)
profile = json.loads(result[0].text)
assert profile["id"] == "123"
assert profile["name"] == "User 123"
async def test_adding_resource_after_mounting(self):
"""Test adding a resource after mounting."""
@ -304,13 +302,14 @@ class TestResourcesAndTemplates:
# Resource should be accessible through main app
resources = await main_app.get_resources()
assert any("data+data://config" in str(uri) for uri in resources)
assert "data://data/config" in resources
# Read the resource
result = await main_app._mcp_read_resource("data+data://config")
assert isinstance(result[0], ReadResourceContents)
config = json.loads(result[0].content)
assert config["version"] == "1.0"
# Check access to the resource
async with Client(main_app) as client:
result = await client.read_resource("data://data/config")
assert isinstance(result[0], TextResourceContents)
config = json.loads(result[0].text)
assert config["version"] == "1.0"
class TestPrompts:
@ -437,7 +436,7 @@ class TestProxyServer:
main_app.mount("proxy", proxy_server)
# Resource should be accessible through main app
result = await main_app._mcp_read_resource("proxy+config://settings")
result = await main_app._mcp_read_resource("config://proxy/settings")
assert isinstance(result[0], ReadResourceContents)
config = json.loads(result[0].content)
assert config["api_key"] == "12345"

View file

@ -927,32 +927,10 @@ class TestMountFastMCP:
assert len(resources) == 4 # Updated to account for new search endpoint
# We're checking the key used by mcp to store the resource
# The prefixed URI is used as the key, but the resource's original uri is preserved
prefixed_uri = "fastapi+resource://openapi/get_users_users_get"
prefixed_uri = "resource://fastapi/openapi/get_users_users_get"
resource = mcp._resource_manager.get_resources().get(prefixed_uri)
assert resource is not None
# Check that templates are available with prefixed URIs
async with Client(mcp) as client:
templates = await client.list_resource_templates()
assert len(templates) == 2
assert templates[0].name == "get_user_users__user_id__get"
prefixed_template_uri = (
r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}"
)
template = mcp._resource_manager.get_templates().get(prefixed_template_uri)
assert template is not None
# Check that tools are available with prefixed names
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 2
assert tools[0].name == "fastapi_create_user_users_post"
assert tools[1].name == "fastapi_update_user_name_users__user_id__name_patch"
async with Client(mcp) as client:
prompts = await client.list_prompts()
assert len(prompts) == 0
async def test_empty_query_parameters_not_sent(
fastapi_app: FastAPI, api_client: httpx.AsyncClient

View file

@ -10,6 +10,12 @@ from pydantic import Field
from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError
from fastmcp.server.server import (
MountedServer,
add_resource_prefix,
has_resource_prefix,
remove_resource_prefix,
)
class TestCreateServer:
@ -754,3 +760,287 @@ class TestPromptDecorator:
assert len(prompts_dict) == 1
prompt = prompts_dict["sample_prompt"]
assert prompt.tags == {"example", "test-tag"}
class TestResourcePrefixHelpers:
@pytest.mark.parametrize(
"uri,prefix,expected",
[
# Normal paths
(
"resource://path/to/resource",
"prefix",
"resource://prefix/path/to/resource",
),
# Absolute paths (with triple slash)
("resource:///absolute/path", "prefix", "resource://prefix//absolute/path"),
# Empty prefix should return the original URI
("resource://path/to/resource", "", "resource://path/to/resource"),
# Different protocols
("file://path/to/file", "prefix", "file://prefix/path/to/file"),
("http://example.com/path", "prefix", "http://prefix/example.com/path"),
# Prefixes with special characters
(
"resource://path/to/resource",
"pre.fix",
"resource://pre.fix/path/to/resource",
),
(
"resource://path/to/resource",
"pre/fix",
"resource://pre/fix/path/to/resource",
),
# Empty paths
("resource://", "prefix", "resource://prefix/"),
],
)
def test_add_resource_prefix(self, uri, prefix, expected):
"""Test that add_resource_prefix correctly adds prefixes to URIs."""
result = add_resource_prefix(uri, prefix)
assert result == expected
@pytest.mark.parametrize(
"invalid_uri",
[
"not-a-uri",
"resource:no-slashes",
"missing-protocol",
"http:/missing-slash",
],
)
def test_add_resource_prefix_invalid_uri(self, invalid_uri):
"""Test that add_resource_prefix raises ValueError for invalid URIs."""
with pytest.raises(ValueError, match="Invalid URI format"):
add_resource_prefix(invalid_uri, "prefix")
@pytest.mark.parametrize(
"uri,prefix,expected",
[
# Normal paths
(
"resource://prefix/path/to/resource",
"prefix",
"resource://path/to/resource",
),
# Absolute paths (with triple slash)
("resource://prefix//absolute/path", "prefix", "resource:///absolute/path"),
# URI without the expected prefix should return the original URI
(
"resource://other/path/to/resource",
"prefix",
"resource://other/path/to/resource",
),
# Empty prefix should return the original URI
("resource://path/to/resource", "", "resource://path/to/resource"),
# Different protocols
("file://prefix/path/to/file", "prefix", "file://path/to/file"),
# Prefixes with special characters (that need escaping in regex)
(
"resource://pre.fix/path/to/resource",
"pre.fix",
"resource://path/to/resource",
),
(
"resource://pre/fix/path/to/resource",
"pre/fix",
"resource://path/to/resource",
),
# Empty paths
("resource://prefix/", "prefix", "resource://"),
],
)
def test_remove_resource_prefix(self, uri, prefix, expected):
"""Test that remove_resource_prefix correctly removes prefixes from URIs."""
result = remove_resource_prefix(uri, prefix)
assert result == expected
@pytest.mark.parametrize(
"invalid_uri",
[
"not-a-uri",
"resource:no-slashes",
"missing-protocol",
"http:/missing-slash",
],
)
def test_remove_resource_prefix_invalid_uri(self, invalid_uri):
"""Test that remove_resource_prefix raises ValueError for invalid URIs."""
with pytest.raises(ValueError, match="Invalid URI format"):
remove_resource_prefix(invalid_uri, "prefix")
@pytest.mark.parametrize(
"uri,prefix,expected",
[
# URI with prefix
("resource://prefix/path/to/resource", "prefix", True),
# URI with another prefix
("resource://other/path/to/resource", "prefix", False),
# URI with prefix as a substring but not at path start
("resource://path/prefix/resource", "prefix", False),
# Empty prefix
("resource://path/to/resource", "", False),
# Different protocols
("file://prefix/path/to/file", "prefix", True),
# Prefix with special characters
("resource://pre.fix/path/to/resource", "pre.fix", True),
# Empty paths
("resource://prefix/", "prefix", True),
],
)
def test_has_resource_prefix(self, uri, prefix, expected):
"""Test that has_resource_prefix correctly identifies prefixes in URIs."""
result = has_resource_prefix(uri, prefix)
assert result == expected
@pytest.mark.parametrize(
"invalid_uri",
[
"not-a-uri",
"resource:no-slashes",
"missing-protocol",
"http:/missing-slash",
],
)
def test_has_resource_prefix_invalid_uri(self, invalid_uri):
"""Test that has_resource_prefix raises ValueError for invalid URIs."""
with pytest.raises(ValueError, match="Invalid URI format"):
has_resource_prefix(invalid_uri, "prefix")
class TestResourcePrefixMounting:
"""Test resource prefixing in mounted servers."""
async def test_mounted_server_resource_prefixing(self):
"""Test that resources in mounted servers use the correct prefix format."""
# Create a server with resources
server = FastMCP(name="ResourceServer")
@server.resource("resource://test-resource")
def get_resource():
return "Resource content"
@server.resource("resource:///absolute/path")
def get_absolute_resource():
return "Absolute resource content"
@server.resource("resource://{param}/template")
def get_template_resource(param: str):
return f"Template resource with {param}"
# Create a main server and mount the resource server
main_server = FastMCP(name="MainServer")
main_server.mount("prefix", server)
# Check that the resources are mounted with the correct prefixes
resources = await main_server.get_resources()
templates = await main_server.get_resource_templates()
assert "resource://prefix/test-resource" in resources
assert "resource://prefix//absolute/path" in resources
assert "resource://prefix/{param}/template" in templates
# Test that prefixed resources can be accessed
async with Client(main_server) as client:
# Regular resource
result = await client.read_resource("resource://prefix/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
# Absolute path resource
result = await client.read_resource("resource://prefix//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
# Template resource
result = await client.read_resource(
"resource://prefix/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"
@pytest.mark.parametrize(
"uri,prefix,expected_match,expected_strip",
[
# Regular resource
(
"resource://prefix/path/to/resource",
"prefix",
True,
"resource://path/to/resource",
),
# Absolute path
(
"resource://prefix//absolute/path",
"prefix",
True,
"resource:///absolute/path",
),
# Non-matching prefix
(
"resource://other/path/to/resource",
"prefix",
False,
"resource://other/path/to/resource",
),
# Different protocol
("http://prefix/example.com", "prefix", True, "http://example.com"),
],
)
async def test_mounted_server_matching_and_stripping(
self, uri, prefix, expected_match, expected_strip
):
"""Test that MountedServer correctly matches and strips resource prefixes."""
# Create a basic server to mount
server = FastMCP()
mounted = MountedServer(prefix=prefix, server=server)
# Test matching
assert mounted.match_resource(uri) == expected_match
# Test stripping
assert mounted.strip_resource_prefix(uri) == expected_strip
async def test_import_server_with_new_prefix_format(self):
"""Test that import_server correctly uses the new prefix format."""
# Create a server with resources
source_server = FastMCP(name="SourceServer")
@source_server.resource("resource://test-resource")
def get_resource():
return "Resource content"
@source_server.resource("resource:///absolute/path")
def get_absolute_resource():
return "Absolute resource content"
@source_server.resource("resource://{param}/template")
def get_template_resource(param: str):
return f"Template resource with {param}"
# Create target server and import the source server
target_server = FastMCP(name="TargetServer")
await target_server.import_server("imported", source_server)
# Check that the resources were imported with the correct prefixes
resources = await target_server.get_resources()
templates = await target_server.get_resource_templates()
assert "resource://imported/test-resource" in resources
assert "resource://imported//absolute/path" in resources
assert "resource://imported/{param}/template" in templates
# Verify we can access the resources
async with Client(target_server) as client:
result = await client.read_resource("resource://imported/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
result = await client.read_resource("resource://imported//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
result = await client.read_resource(
"resource://imported/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"