mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Update resource manager
This commit is contained in:
parent
6b1ba049c7
commit
35e13ef9bf
10 changed files with 353 additions and 218 deletions
|
|
@ -65,14 +65,10 @@ class PromptManager:
|
|||
child_results = await mounted.server._list_prompts()
|
||||
else: # mode == "inventory"
|
||||
# PATH 1: Use the manager-to-manager unfiltered path
|
||||
child_results = await mounted.server._prompt_manager.get_prompts()
|
||||
child_results = await mounted.server._prompt_manager._list_prompts()
|
||||
|
||||
# The combination logic is the same for both paths
|
||||
child_dict = (
|
||||
{p.key: p for p in child_results}
|
||||
if isinstance(child_results, list)
|
||||
else child_results
|
||||
)
|
||||
child_dict = {p.key: p for p in child_results}
|
||||
if mounted.prefix:
|
||||
for prompt in child_dict.values():
|
||||
prefixed_prompt = prompt.with_key(
|
||||
|
|
@ -110,7 +106,7 @@ class PromptManager:
|
|||
"""
|
||||
return await self._load_prompts(mode="inventory")
|
||||
|
||||
async def list_prompts(self) -> list[Prompt]:
|
||||
async def _list_prompts(self) -> list[Prompt]:
|
||||
"""
|
||||
Lists all prompts, applying protocol filtering.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Resource manager functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from pydantic import AnyUrl
|
||||
|
||||
|
|
@ -17,6 +19,9 @@ from fastmcp.resources.template import (
|
|||
from fastmcp.settings import DuplicateBehavior
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import MountedServer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
|
@ -38,6 +43,7 @@ class ResourceManager:
|
|||
"""
|
||||
self._resources: dict[str, Resource] = {}
|
||||
self._templates: dict[str, ResourceTemplate] = {}
|
||||
self._mounted_sources: list[MountedServer] = []
|
||||
self.mask_error_details = mask_error_details or settings.mask_error_details
|
||||
|
||||
# Default to "warn" if None is provided
|
||||
|
|
@ -51,6 +57,128 @@ class ResourceManager:
|
|||
)
|
||||
self.duplicate_behavior = duplicate_behavior
|
||||
|
||||
def mount(self, server: MountedServer) -> None:
|
||||
"""Adds a mounted server as a source for resources and templates."""
|
||||
self._mounted_sources.append(server)
|
||||
|
||||
async def get_resources(self) -> dict[str, Resource]:
|
||||
"""Get all registered resources, keyed by URI."""
|
||||
return await self._load_resources(mode="inventory")
|
||||
|
||||
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
||||
"""Get all registered templates, keyed by URI template."""
|
||||
return await self._load_resource_templates(mode="inventory")
|
||||
|
||||
async def _load_resources(
|
||||
self, *, mode: Literal["inventory", "protocol"]
|
||||
) -> dict[str, Resource]:
|
||||
"""
|
||||
The single, consolidated recursive method for fetching resources. The 'mode'
|
||||
parameter determines the communication path.
|
||||
|
||||
- mode="inventory": Manager-to-manager path for complete, unfiltered inventory
|
||||
- mode="protocol": Server-to-server path for filtered MCP requests
|
||||
"""
|
||||
all_resources: dict[str, Resource] = {}
|
||||
|
||||
for mounted in self._mounted_sources:
|
||||
try:
|
||||
if mode == "protocol":
|
||||
# PATH 2: Use the server-to-server filtered path
|
||||
child_resources_list = await mounted.server._list_resources()
|
||||
child_resources = {
|
||||
resource.key: resource for resource in child_resources_list
|
||||
}
|
||||
else: # mode == "inventory"
|
||||
# PATH 1: Use the manager-to-manager unfiltered path
|
||||
child_resources = (
|
||||
await mounted.server._resource_manager.get_resources()
|
||||
)
|
||||
|
||||
# Apply prefix if needed
|
||||
if mounted.prefix:
|
||||
from fastmcp.server.server import add_resource_prefix
|
||||
|
||||
for uri, resource in child_resources.items():
|
||||
prefixed_uri = add_resource_prefix(
|
||||
uri, mounted.prefix, mounted.resource_prefix_format
|
||||
)
|
||||
# Create a copy of the resource with the prefixed key
|
||||
prefixed_resource = resource.with_key(prefixed_uri)
|
||||
all_resources[prefixed_uri] = prefixed_resource
|
||||
else:
|
||||
all_resources.update(child_resources)
|
||||
except Exception as e:
|
||||
# Skip failed mounts silently, matches existing behavior
|
||||
logger.warning(
|
||||
f"Failed to get resources from mounted server '{mounted.prefix}': {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Finally, add local resources, which always take precedence
|
||||
all_resources.update(self._resources)
|
||||
return all_resources
|
||||
|
||||
async def _load_resource_templates(
|
||||
self, *, mode: Literal["inventory", "protocol"]
|
||||
) -> dict[str, ResourceTemplate]:
|
||||
"""
|
||||
The single, consolidated recursive method for fetching templates. The 'mode'
|
||||
parameter determines the communication path.
|
||||
|
||||
- mode="inventory": Manager-to-manager path for complete, unfiltered inventory
|
||||
- mode="protocol": Server-to-server path for filtered MCP requests
|
||||
"""
|
||||
all_templates: dict[str, ResourceTemplate] = {}
|
||||
|
||||
for mounted in self._mounted_sources:
|
||||
try:
|
||||
if mode == "protocol":
|
||||
# PATH 2: Use the server-to-server filtered path
|
||||
child_templates = await mounted.server._list_resource_templates()
|
||||
else: # mode == "inventory"
|
||||
# PATH 1: Use the manager-to-manager unfiltered path
|
||||
child_templates = await mounted.server._resource_manager._list_resource_templates()
|
||||
child_dict = {template.key: template for template in child_templates}
|
||||
|
||||
# Apply prefix if needed
|
||||
if mounted.prefix:
|
||||
from fastmcp.server.server import add_resource_prefix
|
||||
|
||||
for uri_template, template in child_dict.items():
|
||||
prefixed_uri_template = add_resource_prefix(
|
||||
uri_template, mounted.prefix, mounted.resource_prefix_format
|
||||
)
|
||||
# Create a copy of the template with the prefixed key
|
||||
prefixed_template = template.with_key(prefixed_uri_template)
|
||||
all_templates[prefixed_uri_template] = prefixed_template
|
||||
else:
|
||||
all_templates.update(child_dict)
|
||||
except Exception as e:
|
||||
# Skip failed mounts silently, matches existing behavior
|
||||
logger.warning(
|
||||
f"Failed to get templates from mounted server '{mounted.prefix}': {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Finally, add local templates, which always take precedence
|
||||
all_templates.update(self._templates)
|
||||
return all_templates
|
||||
|
||||
async def _list_resources(self) -> list[Resource]:
|
||||
"""
|
||||
Lists all resources, applying protocol filtering.
|
||||
"""
|
||||
resources_dict = await self._load_resources(mode="protocol")
|
||||
return list(resources_dict.values())
|
||||
|
||||
async def _list_resource_templates(self) -> list[ResourceTemplate]:
|
||||
"""
|
||||
Lists all templates, applying protocol filtering.
|
||||
"""
|
||||
templates_dict = await self._load_resource_templates(mode="protocol")
|
||||
return list(templates_dict.values())
|
||||
|
||||
def add_resource_or_template_from_fn(
|
||||
self,
|
||||
fn: Callable[..., Any],
|
||||
|
|
@ -235,14 +363,21 @@ class ResourceManager:
|
|||
self._templates[storage_key] = template
|
||||
return template
|
||||
|
||||
def has_resource(self, uri: AnyUrl | str) -> bool:
|
||||
async def has_resource(self, uri: AnyUrl | str) -> bool:
|
||||
"""Check if a resource exists."""
|
||||
uri_str = str(uri)
|
||||
if uri_str in self._resources:
|
||||
|
||||
# First check concrete resources (local and mounted)
|
||||
resources = await self.get_resources()
|
||||
if uri_str in resources:
|
||||
return True
|
||||
for template_key in self._templates.keys():
|
||||
|
||||
# Then check templates (local and mounted) only if not found in concrete resources
|
||||
templates = await self.get_resource_templates()
|
||||
for template_key in templates.keys():
|
||||
if match_uri_template(uri_str, template_key):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def get_resource(self, uri: AnyUrl | str) -> Resource:
|
||||
|
|
@ -257,12 +392,14 @@ class ResourceManager:
|
|||
uri_str = str(uri)
|
||||
logger.debug("Getting resource", extra={"uri": uri_str})
|
||||
|
||||
# First check concrete resources
|
||||
if resource := self._resources.get(uri_str):
|
||||
# First check concrete resources (local and mounted)
|
||||
resources = await self.get_resources()
|
||||
if resource := resources.get(uri_str):
|
||||
return resource
|
||||
|
||||
# Then check templates - use the utility function to match against storage keys
|
||||
for storage_key, template in self._templates.items():
|
||||
# Then check templates (local and mounted) - use the utility function to match against storage keys
|
||||
templates = await self.get_resource_templates()
|
||||
for storage_key, template in templates.items():
|
||||
# Try to match against the storage key (which might be a custom key)
|
||||
if params := match_uri_template(uri_str, storage_key):
|
||||
try:
|
||||
|
|
@ -289,31 +426,89 @@ class ResourceManager:
|
|||
raise NotFoundError(f"Unknown resource: {uri_str}")
|
||||
|
||||
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
||||
"""Read a resource contents."""
|
||||
resource = await self.get_resource(uri)
|
||||
"""
|
||||
Internal API for servers: Finds and reads a resource, respecting the
|
||||
filtered protocol path.
|
||||
"""
|
||||
uri_str = str(uri)
|
||||
|
||||
try:
|
||||
return await resource.read()
|
||||
# 1. Check local resources first. The server will have already applied its filter.
|
||||
if uri_str in self._resources:
|
||||
resource = await self.get_resource(uri_str)
|
||||
if not resource:
|
||||
raise NotFoundError(f"Resource {uri_str!r} not found")
|
||||
|
||||
# raise ResourceErrors as-is
|
||||
except ResourceError as e:
|
||||
logger.error(f"Error reading resource {uri!r}: {e}")
|
||||
raise e
|
||||
try:
|
||||
return await resource.read()
|
||||
|
||||
# Handle other exceptions
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading resource {uri!r}: {e}")
|
||||
if self.mask_error_details:
|
||||
# Mask internal details
|
||||
raise ResourceError(f"Error reading resource {uri!r}") from e
|
||||
else:
|
||||
# Include original error details
|
||||
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
|
||||
# raise ResourceErrors as-is
|
||||
except ResourceError as e:
|
||||
logger.exception(f"Error reading resource {uri_str!r}: {e}")
|
||||
raise e
|
||||
|
||||
def get_resources(self) -> dict[str, Resource]:
|
||||
"""Get all registered resources, keyed by URI."""
|
||||
return self._resources
|
||||
# Handle other exceptions
|
||||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri_str!r}: {e}")
|
||||
if self.mask_error_details:
|
||||
# Mask internal details
|
||||
raise ResourceError(f"Error reading resource {uri_str!r}") from e
|
||||
else:
|
||||
# Include original error details
|
||||
raise ResourceError(
|
||||
f"Error reading resource {uri_str!r}: {e}"
|
||||
) from e
|
||||
|
||||
def get_templates(self) -> dict[str, ResourceTemplate]:
|
||||
"""Get all registered templates, keyed by URI template."""
|
||||
return self._templates
|
||||
# 1b. Check local templates if not found in concrete resources
|
||||
for template in self._templates.values():
|
||||
if params := match_uri_template(uri_str, template.uri_template):
|
||||
try:
|
||||
resource = await template.create_resource(uri_str, params=params)
|
||||
return await resource.read()
|
||||
except ResourceError as e:
|
||||
logger.exception(
|
||||
f"Error reading resource from template {uri_str!r}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error reading resource from template {uri_str!r}: {e}"
|
||||
)
|
||||
if self.mask_error_details:
|
||||
raise ResourceError(
|
||||
f"Error reading resource from template {uri_str!r}"
|
||||
) from e
|
||||
else:
|
||||
raise ResourceError(
|
||||
f"Error reading resource from template {uri_str!r}: {e}"
|
||||
) from e
|
||||
|
||||
# 2. Check mounted servers using the filtered protocol path.
|
||||
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
|
||||
|
||||
for mounted in reversed(self._mounted_sources):
|
||||
resource_uri = uri_str
|
||||
try:
|
||||
if mounted.prefix:
|
||||
# If server has a prefix, check if URI matches and strip prefix
|
||||
if has_resource_prefix(
|
||||
resource_uri,
|
||||
mounted.prefix,
|
||||
mounted.resource_prefix_format,
|
||||
):
|
||||
resource_uri = remove_resource_prefix(
|
||||
resource_uri,
|
||||
mounted.prefix,
|
||||
mounted.resource_prefix_format,
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
result = await mounted.server._read_resource(resource_uri)
|
||||
# Extract content from the first ReadResourceContents
|
||||
if result and len(result) > 0:
|
||||
return result[0].content
|
||||
raise NotFoundError(f"Resource {uri_str!r} returned empty content")
|
||||
except NotFoundError:
|
||||
continue
|
||||
|
||||
raise NotFoundError(f"Resource {uri_str!r} not found.")
|
||||
|
|
|
|||
|
|
@ -351,8 +351,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
async def get_resources(self) -> dict[str, Resource]:
|
||||
"""Get all registered resources, indexed by registered key."""
|
||||
resources = await self._list_resources(apply_middleware=False)
|
||||
return {resource.key: resource for resource in resources}
|
||||
return await self._resource_manager.get_resources()
|
||||
|
||||
async def get_resource(self, key: str) -> Resource:
|
||||
resources = await self.get_resources()
|
||||
|
|
@ -362,8 +361,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
||||
"""Get all registered resource templates, indexed by registered key."""
|
||||
templates = await self._list_resource_templates(apply_middleware=False)
|
||||
return {template.key: template for template in templates}
|
||||
return await self._resource_manager.get_resource_templates()
|
||||
|
||||
async def get_resource_template(self, key: str) -> ResourceTemplate:
|
||||
templates = await self.get_resource_templates()
|
||||
|
|
@ -444,7 +442,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async def _handler(
|
||||
context: MiddlewareContext[mcp.types.ListToolsRequest],
|
||||
) -> list[Tool]:
|
||||
tools = await self._tool_manager.list_tools()
|
||||
tools = await self._tool_manager._list_tools() # type: ignore[reportPrivateUsage]
|
||||
|
||||
mcp_tools: list[Tool] = []
|
||||
for tool in tools:
|
||||
|
|
@ -470,12 +468,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
logger.debug("Handler called: list_resources")
|
||||
|
||||
with fastmcp.server.context.Context(fastmcp=self):
|
||||
resources = await self._middleware_list_resources()
|
||||
resources = await self._list_resources()
|
||||
return [
|
||||
resource.to_mcp_resource(uri=resource.key) for resource in resources
|
||||
]
|
||||
|
||||
async def _middleware_list_resources(self) -> list[Resource]:
|
||||
async def _list_resources(self) -> list[Resource]:
|
||||
"""
|
||||
List all available resources, in the format expected by the low-level MCP
|
||||
server.
|
||||
|
|
@ -485,7 +483,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async def _handler(
|
||||
context: MiddlewareContext[dict[str, Any]],
|
||||
) -> list[Resource]:
|
||||
resources = await self._list_resources()
|
||||
resources = await self._resource_manager._list_resources() # type: ignore[reportPrivateUsage]
|
||||
|
||||
mcp_resources: list[Resource] = []
|
||||
for resource in resources:
|
||||
|
|
@ -507,62 +505,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# Apply the middleware chain.
|
||||
return await self._apply_middleware(mw_context, _handler)
|
||||
|
||||
async def _list_resources(self, apply_middleware: bool = True) -> list[Resource]:
|
||||
"""
|
||||
List all available resources.
|
||||
"""
|
||||
|
||||
if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
|
||||
resources: dict[str, Resource] = {}
|
||||
|
||||
# iterate such that new mounts overwrite older ones
|
||||
for mounted_server in self._mounted_servers:
|
||||
try:
|
||||
if apply_middleware:
|
||||
server_resources = (
|
||||
await mounted_server.server._middleware_list_resources()
|
||||
)
|
||||
else:
|
||||
server_resources = await mounted_server.server._list_resources()
|
||||
# Apply prefix to each resource key if prefix exists
|
||||
if mounted_server.prefix:
|
||||
for resource in server_resources:
|
||||
resource = resource.with_key(
|
||||
add_resource_prefix(
|
||||
resource.key,
|
||||
mounted_server.prefix,
|
||||
self.resource_prefix_format,
|
||||
)
|
||||
)
|
||||
resources[resource.key] = resource
|
||||
else:
|
||||
resources.update(
|
||||
{resource.key: resource for resource in server_resources}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}"
|
||||
)
|
||||
continue
|
||||
(
|
||||
local_resources,
|
||||
_,
|
||||
) = await self._resource_manager.get_resources_and_templates()
|
||||
resources.update(local_resources)
|
||||
self._cache.set("resources", resources)
|
||||
return list(resources.values())
|
||||
|
||||
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
||||
logger.debug("Handler called: list_resource_templates")
|
||||
|
||||
with fastmcp.server.context.Context(fastmcp=self):
|
||||
templates = await self._middleware_list_resource_templates()
|
||||
templates = await self._list_resource_templates()
|
||||
return [
|
||||
template.to_mcp_template(uriTemplate=template.key)
|
||||
for template in templates
|
||||
]
|
||||
|
||||
async def _middleware_list_resource_templates(self) -> list[ResourceTemplate]:
|
||||
async def _list_resource_templates(self) -> list[ResourceTemplate]:
|
||||
"""
|
||||
List all available resource templates, in the format expected by the low-level MCP
|
||||
server.
|
||||
|
|
@ -572,7 +525,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async def _handler(
|
||||
context: MiddlewareContext[dict[str, Any]],
|
||||
) -> list[ResourceTemplate]:
|
||||
templates = await self._list_resource_templates()
|
||||
templates = await self._resource_manager._list_resource_templates()
|
||||
|
||||
mcp_templates: list[ResourceTemplate] = []
|
||||
for template in templates:
|
||||
|
|
@ -594,56 +547,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# Apply the middleware chain.
|
||||
return await self._apply_middleware(mw_context, _handler)
|
||||
|
||||
async def _list_resource_templates(
|
||||
self, apply_middleware: bool = True
|
||||
) -> list[ResourceTemplate]:
|
||||
"""
|
||||
List all available resource templates.
|
||||
"""
|
||||
|
||||
if (
|
||||
templates := self._cache.get("resource_templates")
|
||||
) is self._cache.NOT_FOUND:
|
||||
templates: dict[str, ResourceTemplate] = {}
|
||||
|
||||
# iterate such that new mounts overwrite older ones
|
||||
for mounted_server in self._mounted_servers:
|
||||
try:
|
||||
if apply_middleware:
|
||||
server_templates = await mounted_server.server._middleware_list_resource_templates()
|
||||
else:
|
||||
server_templates = (
|
||||
await mounted_server.server._list_resource_templates()
|
||||
)
|
||||
# Apply prefix to each template key if prefix exists
|
||||
if mounted_server.prefix:
|
||||
for template in server_templates:
|
||||
template = template.with_key(
|
||||
add_resource_prefix(
|
||||
template.key,
|
||||
mounted_server.prefix,
|
||||
self.resource_prefix_format,
|
||||
)
|
||||
)
|
||||
templates[template.key] = template
|
||||
else:
|
||||
templates.update(
|
||||
{template.key: template for template in server_templates}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get resource templates from mounted server "
|
||||
f"'{mounted_server.prefix}': {e}"
|
||||
)
|
||||
continue
|
||||
(
|
||||
_,
|
||||
local_templates,
|
||||
) = await self._resource_manager.get_resources_and_templates()
|
||||
templates.update(local_templates)
|
||||
self._cache.set("resource_templates", templates)
|
||||
return list(templates.values())
|
||||
|
||||
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
||||
logger.debug("Handler called: list_prompts")
|
||||
|
||||
|
|
@ -661,7 +564,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async def _handler(
|
||||
context: MiddlewareContext[mcp.types.ListPromptsRequest],
|
||||
) -> list[Prompt]:
|
||||
prompts = await self._prompt_manager.list_prompts()
|
||||
prompts = await self._prompt_manager._list_prompts() # type: ignore[reportPrivateUsage]
|
||||
|
||||
mcp_prompts: list[Prompt] = []
|
||||
for prompt in prompts:
|
||||
|
|
@ -743,7 +646,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
return await self._middleware_read_resource(uri)
|
||||
return await self._read_resource(uri)
|
||||
except DisabledError:
|
||||
# convert to NotFoundError to avoid leaking resource presence
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}")
|
||||
|
|
@ -751,25 +654,28 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# standardize NotFound message
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}")
|
||||
|
||||
async def _middleware_read_resource(
|
||||
self,
|
||||
uri: AnyUrl | str,
|
||||
) -> list[ReadResourceContents]:
|
||||
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
||||
"""
|
||||
Read a resource with middleware.
|
||||
Applies this server's middleware and delegates the filtered call to the manager.
|
||||
"""
|
||||
|
||||
async def _handler(
|
||||
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
||||
) -> list[ReadResourceContents]:
|
||||
return await self._read_resource(
|
||||
uri=context.message.uri,
|
||||
)
|
||||
resource = await self._resource_manager.get_resource(context.message.uri)
|
||||
if not self._should_enable_component(resource):
|
||||
raise NotFoundError(f"Unknown resource: {str(context.message.uri)!r}")
|
||||
|
||||
content = await self._resource_manager.read_resource(context.message.uri)
|
||||
return [
|
||||
ReadResourceContents(
|
||||
content=content,
|
||||
mime_type=resource.mime_type,
|
||||
)
|
||||
]
|
||||
|
||||
# Convert string URI to AnyUrl if needed
|
||||
if isinstance(uri, str):
|
||||
from pydantic import AnyUrl
|
||||
|
||||
uri_param = AnyUrl(uri)
|
||||
else:
|
||||
uri_param = uri
|
||||
|
|
@ -783,25 +689,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
return await self._apply_middleware(mw_context, _handler)
|
||||
|
||||
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
||||
"""
|
||||
Read a resource by URI, in the format expected by the low-level MCP
|
||||
server.
|
||||
"""
|
||||
if await self._resource_manager.has_resource(uri):
|
||||
resource = await self._resource_manager.get_resource(uri)
|
||||
if not self._should_enable_component(resource):
|
||||
raise DisabledError(f"Resource {str(uri)!r} is disabled")
|
||||
content = await self._resource_manager.read_resource(uri)
|
||||
return [
|
||||
ReadResourceContents(
|
||||
content=content,
|
||||
mime_type=resource.mime_type,
|
||||
)
|
||||
]
|
||||
else:
|
||||
raise NotFoundError(f"Unknown resource: {uri}")
|
||||
|
||||
async def _mcp_get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
|
|
@ -1681,7 +1568,11 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
|
||||
|
||||
# Delegate mounting to all three managers
|
||||
mounted_server = MountedServer(prefix=prefix, server=server)
|
||||
mounted_server = MountedServer(
|
||||
prefix=prefix,
|
||||
server=server,
|
||||
resource_prefix_format=self.resource_prefix_format,
|
||||
)
|
||||
self._tool_manager.mount(mounted_server)
|
||||
self._resource_manager.mount(mounted_server)
|
||||
self._prompt_manager.mount(mounted_server)
|
||||
|
|
@ -1979,6 +1870,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
class MountedServer:
|
||||
prefix: str | None
|
||||
server: FastMCP[Any]
|
||||
resource_prefix_format: Literal["protocol", "path"] | None = None
|
||||
|
||||
|
||||
def add_resource_prefix(
|
||||
|
|
|
|||
|
|
@ -66,14 +66,10 @@ class ToolManager:
|
|||
child_results = await mounted.server._list_tools()
|
||||
else: # mode == "inventory"
|
||||
# PATH 1: Use the manager-to-manager unfiltered path
|
||||
child_results = await mounted.server._tool_manager.get_tools()
|
||||
child_results = await mounted.server._tool_manager._list_tools()
|
||||
|
||||
# The combination logic is the same for both paths
|
||||
child_dict = (
|
||||
{t.key: t for t in child_results}
|
||||
if isinstance(child_results, list)
|
||||
else child_results
|
||||
)
|
||||
child_dict = {t.key: t for t in child_results}
|
||||
if mounted.prefix:
|
||||
for tool in child_dict.values():
|
||||
prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}")
|
||||
|
|
@ -109,7 +105,7 @@ class ToolManager:
|
|||
"""
|
||||
return await self._load_tools(mode="inventory")
|
||||
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
async def _list_tools(self) -> list[Tool]:
|
||||
"""
|
||||
Lists all tools, applying protocol filtering.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class TestResourceManager:
|
|||
|
||||
assert "Template already exists" in caplog.text
|
||||
# Should have the template
|
||||
assert manager.get_templates() == {"test://{id}": template}
|
||||
assert manager.get_resource_templates() == {"test://{id}": template}
|
||||
|
||||
def test_error_on_duplicate_templates(self):
|
||||
"""Test error on duplicate templates."""
|
||||
|
|
@ -226,7 +226,7 @@ class TestResourceManager:
|
|||
manager.add_template(template2)
|
||||
|
||||
# Should have replaced with the new template
|
||||
templates = list(manager.get_templates().values())
|
||||
templates = list(manager.get_resource_templates().values())
|
||||
assert len(templates) == 1
|
||||
assert templates[0].name == "replacement"
|
||||
|
||||
|
|
@ -256,7 +256,7 @@ class TestResourceManager:
|
|||
result = manager.add_template(template2)
|
||||
|
||||
# Should keep the original
|
||||
templates = list(manager.get_templates().values())
|
||||
templates = list(manager.get_resource_templates().values())
|
||||
assert len(templates) == 1
|
||||
assert templates[0].name == "original"
|
||||
# Result should be the original template
|
||||
|
|
@ -379,7 +379,7 @@ class TestResourceTags:
|
|||
)
|
||||
|
||||
manager.add_template(template)
|
||||
templates = list(manager.get_templates().values())
|
||||
templates = list(manager.get_resource_templates().values())
|
||||
assert len(templates) == 1
|
||||
assert templates[0].tags == {"users", "template", "data"}
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,18 @@ class TestMiddlewareHooks:
|
|||
assert recording_middleware.assert_called(hook="on_request", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
async def test_read_resource_template(
|
||||
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.read_resource("resource://test-template/1")
|
||||
|
||||
assert recording_middleware.assert_called(times=3)
|
||||
assert recording_middleware.assert_called(method="resources/read", times=3)
|
||||
assert recording_middleware.assert_called(hook="on_message", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_request", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
async def test_get_prompt(
|
||||
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
||||
):
|
||||
|
|
@ -367,6 +379,50 @@ class TestNestedMiddlewareHooks:
|
|||
assert nested_middleware.assert_called(hook="on_request", times=1)
|
||||
assert nested_middleware.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
async def test_read_resource_template_on_parent_server(
|
||||
self,
|
||||
mcp_server: FastMCP,
|
||||
nested_mcp_server: FastMCP,
|
||||
recording_middleware: RecordingMiddleware,
|
||||
nested_middleware: RecordingMiddleware,
|
||||
):
|
||||
mcp_server.mount(nested_mcp_server, prefix="nested")
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.read_resource("resource://test-template/1")
|
||||
|
||||
assert recording_middleware.assert_called(times=3)
|
||||
assert recording_middleware.assert_called(method="resources/read", times=3)
|
||||
assert recording_middleware.assert_called(hook="on_message", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_request", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
assert nested_middleware.assert_called(times=0)
|
||||
|
||||
async def test_read_resource_template_on_nested_server(
|
||||
self,
|
||||
mcp_server: FastMCP,
|
||||
nested_mcp_server: FastMCP,
|
||||
recording_middleware: RecordingMiddleware,
|
||||
nested_middleware: RecordingMiddleware,
|
||||
):
|
||||
mcp_server.mount(nested_mcp_server, prefix="nested")
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.read_resource("resource://nested/test-template/1")
|
||||
|
||||
assert recording_middleware.assert_called(times=3)
|
||||
assert recording_middleware.assert_called(method="resources/read", times=3)
|
||||
assert recording_middleware.assert_called(hook="on_message", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_request", times=1)
|
||||
assert recording_middleware.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
assert nested_middleware.assert_called(times=3)
|
||||
assert nested_middleware.assert_called(method="resources/read", times=3)
|
||||
assert nested_middleware.assert_called(hook="on_message", times=1)
|
||||
assert nested_middleware.assert_called(hook="on_request", times=1)
|
||||
assert nested_middleware.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
async def test_get_prompt_on_parent_server(
|
||||
self,
|
||||
mcp_server: FastMCP,
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@ class TestTagTransfer:
|
|||
):
|
||||
"""Test that tags from OpenAPI routes are correctly transferred to Tools."""
|
||||
# Get internal tools directly (not the public API which returns MCP.Content)
|
||||
tools = fastmcp_openapi_server_with_all_types._tool_manager.list_tools()
|
||||
tools = fastmcp_openapi_server_with_all_types._tool_manager._list_tools()
|
||||
|
||||
# Find the create_user and update_user_name tools
|
||||
create_user_tool = next(
|
||||
|
|
@ -528,7 +528,7 @@ class TestTagTransfer:
|
|||
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
|
||||
# Get internal resource templates directly
|
||||
templates = list(
|
||||
fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values()
|
||||
fastmcp_openapi_server_with_all_types._resource_manager.get_resource_templates().values()
|
||||
)
|
||||
|
||||
# Find the get_user template
|
||||
|
|
@ -549,7 +549,7 @@ class TestTagTransfer:
|
|||
"""Test that tags are preserved when creating resources from templates."""
|
||||
# Get internal resource templates directly
|
||||
templates = list(
|
||||
fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values()
|
||||
fastmcp_openapi_server_with_all_types._resource_manager.get_resource_templates().values()
|
||||
)
|
||||
|
||||
# Find the get_user template
|
||||
|
|
@ -1537,11 +1537,11 @@ class TestFastAPIDescriptionPropagation:
|
|||
print(f" Resource: {name}, Name attribute: {resource.name}")
|
||||
|
||||
print("\nDEBUG - Templates created:")
|
||||
for name, template in server._resource_manager.get_templates().items():
|
||||
for name, template in server._resource_manager.get_resource_templates().items():
|
||||
print(f" Template: {name}, Name attribute: {template.name}")
|
||||
|
||||
print("\nDEBUG - Tools created:")
|
||||
for tool in server._tool_manager.list_tools():
|
||||
for tool in server._tool_manager._list_tools():
|
||||
print(f" Tool: {tool.name}")
|
||||
|
||||
return server
|
||||
|
|
@ -1759,7 +1759,7 @@ class TestReprMethods:
|
|||
self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI
|
||||
):
|
||||
"""Test that OpenAPITool's __repr__ method works without recursion errors."""
|
||||
tools = fastmcp_openapi_server_with_all_types._tool_manager.list_tools()
|
||||
tools = fastmcp_openapi_server_with_all_types._tool_manager._list_tools()
|
||||
tool = next(iter(tools))
|
||||
|
||||
# Verify repr doesn't cause recursion and contains expected elements
|
||||
|
|
@ -1790,7 +1790,7 @@ class TestReprMethods:
|
|||
):
|
||||
"""Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors."""
|
||||
templates = list(
|
||||
fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values()
|
||||
fastmcp_openapi_server_with_all_types._resource_manager.get_resource_templates().values()
|
||||
)
|
||||
template = next(iter(templates))
|
||||
|
||||
|
|
@ -1836,7 +1836,7 @@ class TestEnumHandling:
|
|||
)
|
||||
|
||||
# Get the tools from the server
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
|
||||
# Find the read_item tool
|
||||
read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
|
||||
|
|
@ -1929,7 +1929,7 @@ class TestRouteMapWildcard:
|
|||
)
|
||||
|
||||
# All operations should be mapped to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = mcp._tool_manager._list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
|
||||
# Check that all 4 operations became tools
|
||||
|
|
@ -2246,12 +2246,12 @@ class TestMCPNames:
|
|||
)
|
||||
|
||||
# Check tools use custom names
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "admin_create_user" in tool_names
|
||||
|
||||
# Check resource templates use custom names
|
||||
templates = list(server._resource_manager.get_templates().values())
|
||||
templates = list(server._resource_manager.get_resource_templates().values())
|
||||
template_names = {template.name for template in templates}
|
||||
assert "user_detail" in template_names
|
||||
|
||||
|
|
@ -2276,10 +2276,10 @@ class TestMCPNames:
|
|||
route_maps=GET_ROUTE_MAPS,
|
||||
)
|
||||
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
|
||||
templates = list(server._resource_manager.get_templates().values())
|
||||
templates = list(server._resource_manager.get_resource_templates().values())
|
||||
template_names = {template.name for template in templates}
|
||||
|
||||
resources = list(server._resource_manager.get_resources().values())
|
||||
|
|
@ -2330,13 +2330,13 @@ class TestMCPNames:
|
|||
# Check all component types
|
||||
all_names = []
|
||||
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
all_names.extend(tool.name for tool in tools)
|
||||
|
||||
resources = list(server._resource_manager.get_resources().values())
|
||||
all_names.extend(resource.name for resource in resources)
|
||||
|
||||
templates = list(server._resource_manager.get_templates().values())
|
||||
templates = list(server._resource_manager.get_resource_templates().values())
|
||||
all_names.extend(template.name for template in templates)
|
||||
|
||||
# All names should be 56 characters or less
|
||||
|
|
@ -2363,7 +2363,7 @@ class TestMCPNames:
|
|||
mcp_names=mcp_names,
|
||||
)
|
||||
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "openapi_user_list" in tool_names
|
||||
|
||||
|
|
@ -2395,7 +2395,7 @@ class TestMCPNames:
|
|||
mcp_names=mcp_names,
|
||||
)
|
||||
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
|
||||
assert "fastapi_create_user" in tool_names
|
||||
|
|
@ -2496,7 +2496,7 @@ class TestRouteMapMCPTags:
|
|||
)
|
||||
|
||||
# Get the POST tool
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
create_user_tool = next((t for t in tools if "create_user" in t.name), None)
|
||||
|
||||
assert create_user_tool is not None, "create_user tool not found"
|
||||
|
|
@ -2564,7 +2564,7 @@ class TestRouteMapMCPTags:
|
|||
)
|
||||
|
||||
# Get the resource template
|
||||
templates = list(server._resource_manager.get_templates().values())
|
||||
templates = list(server._resource_manager.get_resource_templates().values())
|
||||
get_user_template = next((t for t in templates if "get_user" in t.name), None)
|
||||
|
||||
assert get_user_template is not None, "get_user template not found"
|
||||
|
|
@ -2610,14 +2610,14 @@ class TestRouteMapMCPTags:
|
|||
)
|
||||
|
||||
# Check tool tags
|
||||
tools = server._tool_manager.list_tools()
|
||||
tools = server._tool_manager._list_tools()
|
||||
create_tool = next((t for t in tools if "create_user" in t.name), None)
|
||||
assert create_tool is not None
|
||||
assert "write-operation" in create_tool.tags
|
||||
assert "mutation" in create_tool.tags
|
||||
|
||||
# Check resource template tags
|
||||
templates = list(server._resource_manager.get_templates().values())
|
||||
templates = list(server._resource_manager.get_resource_templates().values())
|
||||
detail_template = next((t for t in templates if "get_user" in t.name), None)
|
||||
assert detail_template is not None
|
||||
assert "detail" in detail_template.tags
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ class TestToolDecorator:
|
|||
return x * 2
|
||||
|
||||
# Verify the tags were set correctly
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = await mcp._tool_manager._list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].tags == {"example", "test-tag"}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ async def test_tool_annotations_in_tool_manager():
|
|||
return message
|
||||
|
||||
# Check internal tool objects directly
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = mcp._tool_manager._list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].annotations is not None
|
||||
assert tools[0].annotations.title == "Echo Tool"
|
||||
|
|
@ -124,7 +124,7 @@ async def test_direct_tool_annotations_in_tool_manager():
|
|||
return {"modified": True, **data}
|
||||
|
||||
# Check internal tool objects directly
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = mcp._tool_manager._list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].annotations is not None
|
||||
assert tools[0].annotations.title == "Direct Tool"
|
||||
|
|
@ -183,7 +183,7 @@ async def test_add_tool_method_annotations():
|
|||
mcp.add_tool(tool)
|
||||
|
||||
# Check internal tool objects directly
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = mcp._tool_manager._list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].annotations is not None
|
||||
assert tools[0].annotations.title == "Create Item"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ async def test_tool_exclude_args_in_tool_manager():
|
|||
pass
|
||||
return message
|
||||
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = mcp._tool_manager._list_tools()
|
||||
assert len(tools) == 1
|
||||
assert "state" not in echo.parameters["properties"]
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ async def test_add_tool_method_exclude_args():
|
|||
mcp.add_tool(tool)
|
||||
|
||||
# Check internal tool objects directly
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tools = mcp._tool_manager._list_tools()
|
||||
assert len(tools) == 1
|
||||
assert "state" not in tools[0].parameters["properties"]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue