mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Remove customizable separators; improve resource separator
This commit is contained in:
parent
7e2d4ef0ab
commit
cec40ddfea
9 changed files with 720 additions and 210 deletions
|
|
@ -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>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
0
tests/deprecated/__init__.py
Normal file
0
tests/deprecated/__init__.py
Normal 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="-")
|
||||
85
tests/deprecated/test_mount_separators.py
Normal file
85
tests/deprecated/test_mount_separators.py
Normal 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="-")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue