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

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"