Update resource manager

This commit is contained in:
Jeremiah Lowin 2025-06-19 12:22:14 -04:00
commit 35e13ef9bf
10 changed files with 353 additions and 218 deletions

View file

@ -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.
"""

View file

@ -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.")

View file

@ -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(

View file

@ -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.
"""