Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions src/fastmcp/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,8 @@ def __repr__(self) -> str:

@property
def key(self) -> str:
"""
The key of the component. This is used for internal bookkeeping
and may reflect e.g. prefixes or other identifiers. You should not depend on
keys having a certain value, as the same tool loaded from different
hierarchies of servers may have different keys.
"""
return self._key or str(self.uri)
"""The lookup key for this resource. Returns str(uri)."""
return str(self.uri)


class FunctionResource(Resource):
Expand Down
8 changes: 4 additions & 4 deletions src/fastmcp/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ def add_resource(self, resource: Resource) -> Resource:

Args:
resource: A Resource instance to add. The resource's .key attribute
will be used as the storage key. To overwrite it, call
Resource.model_copy(key=new_key) before calling this method.
(which is str(uri)) will be used as the storage key. To use a
different key, change the uri via model_copy(update={"uri": new_uri}).
"""
existing = self._resources.get(resource.key)
if existing:
Expand Down Expand Up @@ -203,8 +203,8 @@ def add_template(self, template: ResourceTemplate) -> ResourceTemplate:

Args:
template: A ResourceTemplate instance to add. The template's .key attribute
will be used as the storage key. To overwrite it, call
ResourceTemplate.model_copy(key=new_key) before calling this method.
(which is uri_template) will be used as the storage key. To use a
different key, change uri_template via model_copy(update={"uri_template": new_uri}).

Returns:
The added template. If a template with the same URI already exists,
Expand Down
9 changes: 2 additions & 7 deletions src/fastmcp/resources/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,8 @@ def from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplat

@property
def key(self) -> str:
"""
The key of the component. This is used for internal bookkeeping
and may reflect e.g. prefixes or other identifiers. You should not depend on
keys having a certain value, as the same tool loaded from different
hierarchies of servers may have different keys.
"""
return self._key or self.uri_template
"""The lookup key for this template. Returns uri_template."""
return self.uri_template


class FunctionResourceTemplate(ResourceTemplate):
Expand Down
42 changes: 20 additions & 22 deletions src/fastmcp/server/providers/mounted.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,39 +179,37 @@ def _strip_resource_prefix(self, uri: str) -> str | None:
# -------------------------------------------------------------------------

def _prefix_tool(self, tool: Tool) -> Tool:
"""Apply prefix to a tool."""
"""Apply prefix to a tool. Changes name which updates .key."""
if self.tool_names and tool.name in self.tool_names:
new_key = self.tool_names[tool.name]
new_name = self.tool_names[tool.name]
else:
new_key = self._add_tool_prefix(tool.key)
return tool.model_copy(key=new_key) if new_key != tool.key else tool
new_name = self._add_tool_prefix(tool.name)
if new_name != tool.name:
return tool.model_copy(update={"name": new_name})
return tool

def _prefix_resource(self, resource: Resource) -> Resource:
"""Apply prefix to a resource."""
new_key = self._add_resource_prefix(resource.key)
update: dict[str, Any] = {}
if self.prefix and resource.name:
update["name"] = f"{self.prefix}_{resource.name}"
if new_key != resource.key or update:
return resource.model_copy(key=new_key, update=update)
"""Apply prefix to a resource URI (name is NOT prefixed)."""
if self.prefix:
new_uri = self._add_resource_prefix(str(resource.uri))
if new_uri != str(resource.uri):
return resource.model_copy(update={"uri": new_uri})
return resource

def _prefix_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Apply prefix to a resource template."""
new_key = self._add_resource_prefix(template.key)
update: dict[str, Any] = {}
if self.prefix and template.name:
update["name"] = f"{self.prefix}_{template.name}"
"""Apply prefix to a resource template URI (name is NOT prefixed)."""
if self.prefix and template.uri_template:
update["uri_template"] = self._add_resource_prefix(template.uri_template)
if new_key != template.key or update:
return template.model_copy(key=new_key, update=update)
new_template = self._add_resource_prefix(template.uri_template)
if new_template != template.uri_template:
return template.model_copy(update={"uri_template": new_template})
return template

def _prefix_prompt(self, prompt: Prompt) -> Prompt:
"""Apply prefix to a prompt."""
new_key = self._add_tool_prefix(prompt.key)
return prompt.model_copy(key=new_key) if new_key != prompt.key else prompt
"""Apply prefix to a prompt. Changes name which updates .key."""
new_name = self._add_tool_prefix(prompt.name)
if new_name != prompt.name:
return prompt.model_copy(update={"name": new_name})
return prompt

# -------------------------------------------------------------------------
# Tool methods
Expand Down
56 changes: 51 additions & 5 deletions src/fastmcp/server/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,21 @@ class ProxyTool(Tool, MirroredComponent):
"""

task_config: TaskConfig = TaskConfig(mode="forbidden")
_backend_name: str | None = None

def __init__(self, client: Client, **kwargs: Any):
super().__init__(**kwargs)
self._client = client

def model_copy(self, **kwargs: Any) -> ProxyTool:
"""Override to preserve _backend_name when name changes."""
update = kwargs.get("update", {})
if "name" in update and self._backend_name is None:
# First time name is being changed, preserve original for backend calls
update = {**update, "_backend_name": self.name}
kwargs["update"] = update
return super().model_copy(**kwargs) # type: ignore[return-value]

@classmethod
def from_mcp_tool(cls, client: Client, mcp_tool: mcp.types.Tool) -> ProxyTool:
"""Factory method to create a ProxyTool from a raw MCP tool schema."""
Expand Down Expand Up @@ -331,7 +341,7 @@ async def run(
)

result = await self._client.call_tool_mcp(
name=self.name, arguments=arguments, meta=meta
name=self._backend_name or self.name, arguments=arguments, meta=meta
)
if result.isError:
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
Expand All @@ -351,6 +361,7 @@ class ProxyResource(Resource, MirroredComponent):
task_config: TaskConfig = TaskConfig(mode="forbidden")
_client: Client
_cached_content: ResourceContent | None = None
_backend_uri: str | None = None

def __init__(
self,
Expand All @@ -363,6 +374,15 @@ def __init__(
self._client = client
self._cached_content = _cached_content

def model_copy(self, **kwargs: Any) -> ProxyResource:
"""Override to preserve _backend_uri when uri changes."""
update = kwargs.get("update", {})
if "uri" in update and self._backend_uri is None:
# First time uri is being changed, preserve original for backend calls
update = {**update, "_backend_uri": str(self.uri)}
kwargs["update"] = update
return super().model_copy(**kwargs) # type: ignore[return-value]

@classmethod
def from_mcp_resource(
cls,
Expand Down Expand Up @@ -390,10 +410,13 @@ async def read(self) -> ResourceContent:
if self._cached_content is not None:
return self._cached_content

backend_uri = self._backend_uri or str(self.uri)
async with self._client:
result = await self._client.read_resource(self.uri)
result = await self._client.read_resource(backend_uri)
if not result:
raise ResourceError(f"Remote server returned empty content for {self.uri}")
raise ResourceError(
f"Remote server returned empty content for {backend_uri}"
)
if isinstance(result[0], TextResourceContents):
return ResourceContent(
content=result[0].text,
Expand All @@ -416,11 +439,21 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
"""

task_config: TaskConfig = TaskConfig(mode="forbidden")
_backend_uri_template: str | None = None

def __init__(self, client: Client, **kwargs: Any):
super().__init__(**kwargs)
self._client = client

def model_copy(self, **kwargs: Any) -> ProxyTemplate:
"""Override to preserve _backend_uri_template when uri_template changes."""
update = kwargs.get("update", {})
if "uri_template" in update and self._backend_uri_template is None:
# First time uri_template is being changed, preserve original for backend
update = {**update, "_backend_uri_template": self.uri_template}
kwargs["update"] = update
return super().model_copy(**kwargs) # type: ignore[return-value]

@classmethod
def from_mcp_template( # type: ignore[override]
cls, client: Client, mcp_template: mcp.types.ResourceTemplate
Expand Down Expand Up @@ -451,7 +484,8 @@ async def create_resource(
# don't use the provided uri, because it may not be the same as the
# uri_template on the remote server.
# quote params to ensure they are valid for the uri_template
parameterized_uri = self.uri_template.format(
backend_template = self._backend_uri_template or self.uri_template
parameterized_uri = backend_template.format(
**{k: quote(v, safe="") for k, v in params.items()}
)
async with self._client:
Expand Down Expand Up @@ -497,11 +531,21 @@ class ProxyPrompt(Prompt, MirroredComponent):

task_config: TaskConfig = TaskConfig(mode="forbidden")
_client: Client
_backend_name: str | None = None

def __init__(self, client: Client, **kwargs):
super().__init__(**kwargs)
self._client = client

def model_copy(self, **kwargs: Any) -> ProxyPrompt:
"""Override to preserve _backend_name when name changes."""
update = kwargs.get("update", {})
if "name" in update and self._backend_name is None:
# First time name is being changed, preserve original for backend calls
update = {**update, "_backend_name": self.name}
kwargs["update"] = update
return super().model_copy(**kwargs) # type: ignore[return-value]

@classmethod
def from_mcp_prompt(
cls, client: Client, mcp_prompt: mcp.types.Prompt
Expand Down Expand Up @@ -531,7 +575,9 @@ def from_mcp_prompt(
async def render(self, arguments: dict[str, Any]) -> PromptResult: # type: ignore[override]
"""Render the prompt by making a call through the client."""
async with self._client:
result = await self._client.get_prompt(self.name, arguments)
result = await self._client.get_prompt(
self._backend_name or self.name, arguments
)
# Convert GetPromptResult to PromptResult, preserving runtime meta from the result
# (not the static prompt meta which includes fastmcp tags)
return PromptResult(
Expand Down
30 changes: 14 additions & 16 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,14 +442,14 @@ async def _docket_lifespan(self) -> AsyncIterator[None]:
isinstance(resource, FunctionResource)
and resource.task_config.mode != "forbidden"
):
docket.register(resource.fn, names=[resource.name])
docket.register(resource.fn, names=[resource.key])

for template in self._resource_manager._templates.values():
if (
isinstance(template, FunctionResourceTemplate)
and template.task_config.mode != "forbidden"
):
docket.register(template.fn, names=[template.name])
docket.register(template.fn, names=[template.key])

# Register provider components
for provider in self._providers:
Expand All @@ -458,9 +458,9 @@ async def _docket_lifespan(self) -> AsyncIterator[None]:
for tool in tasks.tools:
docket.register(tool.fn, names=[tool.key])
for resource in tasks.resources:
docket.register(resource.fn, names=[resource.name])
docket.register(resource.fn, names=[resource.key])
for template in tasks.templates:
docket.register(template.fn, names=[template.name])
docket.register(template.fn, names=[template.key])
for prompt in tasks.prompts:
docket.register(
cast(Callable[..., Awaitable[Any]], prompt.fn),
Expand Down Expand Up @@ -2755,32 +2755,30 @@ async def import_server(
)

# Import tools from the server
for key, tool in (await server.get_tools()).items():
for tool in (await server.get_tools()).values():
if prefix:
tool = tool.model_copy(key=f"{prefix}_{key}")
tool = tool.model_copy(update={"name": f"{prefix}_{tool.name}"})
self._tool_manager.add_tool(tool)

# Import resources and templates from the server
for key, resource in (await server.get_resources()).items():
for resource in (await server.get_resources()).values():
if prefix:
resource_key = add_resource_prefix(key, prefix)
resource = resource.model_copy(
update={"name": f"{prefix}_{resource.name}"}, key=resource_key
)
new_uri = add_resource_prefix(str(resource.uri), prefix)
resource = resource.model_copy(update={"uri": new_uri})
self._resource_manager.add_resource(resource)

for key, template in (await server.get_resource_templates()).items():
for template in (await server.get_resource_templates()).values():
if prefix:
template_key = add_resource_prefix(key, prefix)
new_uri_template = add_resource_prefix(template.uri_template, prefix)
template = template.model_copy(
update={"name": f"{prefix}_{template.name}"}, key=template_key
update={"uri_template": new_uri_template}
)
self._resource_manager.add_template(template)

# Import prompts from the server
for key, prompt in (await server.get_prompts()).items():
for prompt in (await server.get_prompts()).values():
if prefix:
prompt = prompt.model_copy(key=f"{prefix}_{key}")
prompt = prompt.model_copy(update={"name": f"{prefix}_{prompt.name}"})
self._prompt_manager.add_prompt(prompt)

if server._lifespan != default_lifespan:
Expand Down
11 changes: 5 additions & 6 deletions src/fastmcp/server/tasks/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async def handle_tool_as_task(
# Don't let notification failures break task creation
await ctx.session.send_notification(notification) # type: ignore[arg-type]

# Queue function to Docket by name (result storage via execution_ttl)
# Queue function to Docket by key (result storage via execution_ttl)
# Use tool.key which matches what was registered - prefixed for mounted tools
await docket.add(
tool.key,
Expand Down Expand Up @@ -205,7 +205,7 @@ async def handle_prompt_as_task(
with suppress(Exception):
await ctx.session.send_notification(notification) # type: ignore[arg-type]

# Queue function to Docket by name (result storage via execution_ttl)
# Queue function to Docket by key (result storage via execution_ttl)
# Use prompt.key which matches what was registered - prefixed for mounted prompts
await docket.add(
prompt.key,
Expand Down Expand Up @@ -309,20 +309,19 @@ async def handle_resource_as_task(
with suppress(Exception):
await ctx.session.send_notification(notification) # type: ignore[arg-type]

# Queue function to Docket by name (result storage via execution_ttl)
# Use resource.name which matches what was registered - prefixed for mounted resources
# Queue function to Docket by key (result storage via execution_ttl)
# For templates, extract URI params and pass them to the function
from fastmcp.resources.template import FunctionResourceTemplate, match_uri_template

if isinstance(resource, FunctionResourceTemplate):
params = match_uri_template(uri, resource.uri_template) or {}
await docket.add(
resource.name,
resource.key,
key=task_key,
)(**params)
else:
await docket.add(
resource.name,
resource.key,
key=task_key,
)()

Expand Down
Loading
Loading