Repository: jlowin/fastmcp
Pull request #173 (2025-04-15 16:55:15)

Create MCP prompts on object; fix issue with forwarding proxy templates
This pull request introduces several enhancements and refactors to the `fastmcp` package, focusing on prompt management, resource handling, and proxy functionality. The most important changes include adding methods to convert prompts to MCP format, updating prompt and tool management to use keys instead of names, and ensuring proper handling of URI parameters.

### Prompt Management Enhancements:
* Added `to_mcp_prompt` method in `Prompt` to convert prompts to MCP format (`src/fastmcp/prompts/prompt.py`).
* Introduced methods in `PromptManager` to list MCP prompts and refactored prompt retrieval to use keys (`src/fastmcp/prompts/prompt_manager.py`). [[1]](diffhunk://#diff-39627f28d8caefc4636d1b2b8b4f42d5f81198a2a63721528195fadd60a4d940L33-R48) [[2]](diffhunk://#diff-39627f28d8caefc4636d1b2b8b4f42d5f81198a2a63721528195fadd60a4d940L52-R78)

### Resource Handling Improvements:
* Updated `match_uri_template` to decode URI parameters (`src/fastmcp/resources/template.py`).
* Modified `ProxyResource` to ensure URI parameters are properly quoted (`src/fastmcp/server/proxy.py`). [[1]](diffhunk://#diff-389b70f78a7735544dd555709ae930f1a2d6145f03b02ee7af257f246744a951R108-R115) [[2]](diffhunk://#diff-389b70f78a7735544dd555709ae930f1a2d6145f03b02ee7af257f246744a951L119-R126)

### Tool Management Refactor:
* Changed `ToolManager` to use keys instead of names for tool identification (`src/fastmcp/tools/tool_manager.py`). [[1]](diffhunk://#diff-903cc94b53e29fe31add3b97868237781872c6fdfd2748e173aae3317ea724d7L39-R41) [[2]](diffhunk://#diff-903cc94b53e29fe31add3b97868237781872c6fdfd2748e173aae3317ea724d7L112-R114)

### Testing Enhancements:
* Added tests for handling quoted URI parameters and proxy prompt/resource functionality (`tests/resources/test_resource_template.py`, `tests/server/test_mount.py`). [[1]](diffhunk://#diff-eac160c7284054f03ecfe3e46a211c53a960c5e39e4fa1b0c772bfe2be8bf577R316-R327) [[2]](diffhunk://#diff-463462c4a319da050e2d183132c8047ecdc5b705a3eab07ad3c443a39e2a0a0dR349-R428)

These changes improve the flexibility and robustness of the `fastmcp` package, particularly in handling prompts and resources in a more consistent and scalable manner.


Patch:
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index 06cc233..5733b37 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -7,6 +7,8 @@ from typing import Annotated, Any, Literal
 
 import pydantic_core
 from mcp.types import EmbeddedResource, ImageContent, TextContent
+from mcp.types import Prompt as MCPPrompt
+from mcp.types import PromptArgument as MCPPromptArgument
 from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
 
 from fastmcp.utilities.types import _convert_set_defaults
@@ -166,3 +168,20 @@ class Prompt(BaseModel):
         if not isinstance(other, Prompt):
             return False
         return self.model_dump() == other.model_dump()
+
+    def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
+        """Convert the prompt to an MCP prompt."""
+        arguments = [
+            MCPPromptArgument(
+                name=arg.name,
+                description=arg.description,
+                required=arg.required,
+            )
+            for arg in self.arguments or []
+        ]
+        kwargs = {
+            "name": self.name,
+            "description": self.description,
+            "arguments": arguments,
+        }
+        return MCPPrompt(**kwargs | overrides)
diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py
index 30f9715..ce423c7 100644
--- a/src/fastmcp/prompts/prompt_manager.py
+++ b/src/fastmcp/prompts/prompt_manager.py
@@ -1,11 +1,10 @@
 """Prompt management functionality."""
 
-import copy
 from collections.abc import Awaitable, Callable
 from typing import Any
 
 from fastmcp.exceptions import PromptError
-from fastmcp.prompts.prompt import Message, Prompt, PromptResult
+from fastmcp.prompts.prompt import MCPPrompt, Message, Prompt, PromptResult
 from fastmcp.settings import DuplicateBehavior
 from fastmcp.utilities.logging import get_logger
 
@@ -30,13 +29,23 @@ class PromptManager:
 
         self.duplicate_behavior = duplicate_behavior
 
-    def get_prompt(self, name: str) -> Prompt | None:
-        """Get prompt by name."""
-        return self._prompts.get(name)
+    def get_prompt(self, key: str) -> Prompt | None:
+        """Get prompt by key."""
+        return self._prompts.get(key)
+
+    def get_prompts(self) -> dict[str, Prompt]:
+        """Get all registered prompts, indexed by registered key."""
+        return self._prompts
 
     def list_prompts(self) -> list[Prompt]:
         """List all registered prompts."""
-        return list(self._prompts.values())
+        return list(self.get_prompts().values())
+
+    def list_mcp_prompts(self) -> list[MCPPrompt]:
+        """List all registered prompts in the format expected by the low-level MCP server."""
+        return [
+            prompt.to_mcp_prompt(name=key) for key, prompt in self.get_prompts().items()
+        ]
 
     def add_prompt_from_fn(
         self,
@@ -49,23 +58,24 @@ class PromptManager:
         prompt = Prompt.from_function(fn, name=name, description=description, tags=tags)
         return self.add_prompt(prompt)
 
-    def add_prompt(self, prompt: Prompt) -> Prompt:
+    def add_prompt(self, prompt: Prompt, key: str | None = None) -> Prompt:
         """Add a prompt to the manager."""
+        key = key or prompt.name
 
         # Check for duplicates
-        existing = self._prompts.get(prompt.name)
+        existing = self._prompts.get(key)
         if existing:
             if self.duplicate_behavior == "warn":
-                logger.warning(f"Prompt already exists: {prompt.name}")
-                self._prompts[prompt.name] = prompt
+                logger.warning(f"Prompt already exists: {key}")
+                self._prompts[key] = prompt
             elif self.duplicate_behavior == "replace":
-                self._prompts[prompt.name] = prompt
+                self._prompts[key] = prompt
             elif self.duplicate_behavior == "error":
-                raise ValueError(f"Prompt already exists: {prompt.name}")
+                raise ValueError(f"Prompt already exists: {key}")
             elif self.duplicate_behavior == "ignore":
                 return existing
         else:
-            self._prompts[prompt.name] = prompt
+            self._prompts[key] = prompt
         return prompt
 
     async def render_prompt(
@@ -86,19 +96,16 @@ class PromptManager:
 
         Args:
             manager: Another PromptManager instance to import prompts from
-            prefix: Prefix to add to prompt names. The resulting prompt name will
+            prefix: Prefix to add to prompt names. The resulting prompt key will
                    be in the format "{prefix}{original_name}" if prefix is provided,
                    otherwise the original name is used.
                    For example, with prefix "weather/" and prompt "forecast_prompt",
                    the imported prompt would be available as "weather/forecast_prompt"
         """
         for name, prompt in manager._prompts.items():
-            # Create prefixed name
-            prefixed_name = f"{prefix}{name}" if prefix else name
-
-            new_prompt = copy.copy(prompt)
-            new_prompt.name = prefixed_name
+            # Create prefixed key
+            key = f"{prefix}{name}" if prefix else name
 
-            # Store the prompt with the prefixed name
-            self.add_prompt(new_prompt)
-            logger.debug(f'Imported prompt "{name}" as "{prefixed_name}"')
+            # Store the prompt with the prefixed key
+            self.add_prompt(prompt, key=key)
+            logger.debug(f'Imported prompt "{prompt.name}" as "{key}"')
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index 08f3958..bba1c44 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -6,6 +6,7 @@ import inspect
 import re
 from collections.abc import Callable
 from typing import Annotated, Any
+from urllib.parse import unquote
 
 from mcp.types import ResourceTemplate as MCPResourceTemplate
 from pydantic import (
@@ -38,7 +39,9 @@ def build_regex(template: str) -> re.Pattern:
 def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
     regex = build_regex(uri_template)
     match = regex.match(uri)
-    return match.groupdict() if match else None
+    if match:
+        return {k: unquote(v) for k, v in match.groupdict().items()}
+    return None
 
 
 class MyModel(BaseModel):
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index 414dcba..887e60e 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -1,4 +1,5 @@
 from typing import Any, cast
+from urllib.parse import quote
 
 import mcp.types
 from mcp.types import BlobResourceContents, TextResourceContents
@@ -104,8 +105,14 @@ class ProxyTemplate(ResourceTemplate):
         )
 
     async def create_resource(self, uri: str, params: dict[str, Any]) -> ProxyResource:
+        # dont 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(
+            **{k: quote(v, safe="") for k, v in params.items()}
+        )
         async with self._client:
-            result = await self._client.read_resource(uri)
+            result = await self._client.read_resource(parameterized_uri)
 
         if isinstance(result[0], TextResourceContents):
             value = result[0].text
@@ -116,7 +123,7 @@ class ProxyTemplate(ResourceTemplate):
 
         return ProxyResource(
             client=self._client,
-            uri=uri,
+            uri=parameterized_uri,
             name=self.name,
             description=self.description,
             mime_type=result[0].mimeType,
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index b99e5d8..d1c0408 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -28,7 +28,6 @@ from mcp.types import (
     TextContent,
 )
 from mcp.types import Prompt as MCPPrompt
-from mcp.types import PromptArgument as MCPPromptArgument
 from mcp.types import Resource as MCPResource
 from mcp.types import ResourceTemplate as MCPResourceTemplate
 from mcp.types import Tool as MCPTool
@@ -217,11 +216,11 @@ class FastMCP(Generic[LifespanResultT]):
         return Context(request_context=request_context, fastmcp=self)
 
     async def call_tool(
-        self, name: str, arguments: dict[str, Any]
+        self, key: str, arguments: dict[str, Any]
     ) -> list[TextContent | ImageContent | EmbeddedResource]:
         """Call a tool by name with arguments."""
         context = self.get_context()
-        result = await self._tool_manager.call_tool(name, arguments, context=context)
+        result = await self._tool_manager.call_tool(key, arguments, context=context)
         converted_result = _convert_to_content(result)
         return converted_result
 
@@ -592,22 +591,7 @@ class FastMCP(Generic[LifespanResultT]):
 
         See `list_prompts` for a more ergonomic way to list prompts.
         """
-        prompts = self.list_prompts()
-        return [
-            MCPPrompt(
-                name=prompt.name,
-                description=prompt.description,
-                arguments=[
-                    MCPPromptArgument(
-                        name=arg.name,
-                        description=arg.description,
-                        required=arg.required,
-                    )
-                    for arg in (prompt.arguments or [])
-                ],
-            )
-            for prompt in prompts
-        ]
+        return self._prompt_manager.list_mcp_prompts()
 
     async def get_prompt(
         self, name: str, arguments: dict[str, Any] | None = None
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index 9e0f1ac..f11d98e 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -36,9 +36,9 @@ class ToolManager:
 
         self.duplicate_behavior = duplicate_behavior
 
-    def get_tool(self, name: str) -> Tool | None:
-        """Get tool by name."""
-        return self._tools.get(name)
+    def get_tool(self, key: str) -> Tool | None:
+        """Get tool by key."""
+        return self._tools.get(key)
 
     def get_tools(self) -> dict[str, Tool]:
         """Get all registered tools, indexed by registered key."""
@@ -109,6 +109,6 @@ class ToolManager:
                    the imported tool would be available as "weather/forecast"
         """
         for name, tool in tool_manager._tools.items():
-            prefixed_name = f"{prefix}{name}" if prefix else name
-            self.add_tool(tool, key=prefixed_name)
-            logger.debug(f'Imported tool "{tool.name}" as "{prefixed_name}"')
+            key = f"{prefix}{name}" if prefix else name
+            self.add_tool(tool, key=key)
+            logger.debug(f'Imported tool "{tool.name}" as "{key}"')


Test patch:
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 4f6638e..301a482 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -1,4 +1,5 @@
 import json
+from urllib.parse import quote
 
 import pytest
 from pydantic import BaseModel
@@ -312,6 +313,18 @@ class TestMatchUriTemplate:
             ("test://foo/123", {"x": "foo", "y": "123"}),
             ("test://bar/456", {"x": "bar", "y": "456"}),
             ("test://foo/bar", {"x": "foo", "y": "bar"}),
+            ("test://foo/bar/baz", None),
+            ("test://foo/email@domain.com", {"x": "foo", "y": "email@domain.com"}),
+            ("test://two words/foo", {"x": "two words", "y": "foo"}),
+            ("test://two.words/foo+bar", {"x": "two.words", "y": "foo+bar"}),
+            (
+                f"test://escaped{quote('/', safe='')}word/bar",
+                {"x": "escaped/word", "y": "bar"},
+            ),
+            (
+                f"test://escaped{quote('{', safe='')}x{quote('}', safe='')}word/bar",
+                {"x": "escaped{x}word", "y": "bar"},
+            ),
             ("prefix+test://foo/123", None),
             ("test://foo", None),
             ("other://foo/123", None),
@@ -361,3 +374,11 @@ class TestMatchUriTemplate:
         uri_template = "prefix+test://{x}/test/{y}"
         result = match_uri_template(uri=uri, uri_template=uri_template)
         assert result == expected_params
+
+    def test_quoted_params(self):
+        uri_template = "user://{name}/{email}"
+        quoted_name = quote("John Doe", safe="")
+        quoted_email = quote("john@example.com", safe="")
+        uri = f"user://{quoted_name}/{quoted_email}"
+        result = match_uri_template(uri=uri, uri_template=uri_template)
+        assert result == {"name": "John Doe", "email": "john@example.com"}
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
index 5b3c799..e66e65c 100644
--- a/tests/server/test_mount.py
+++ b/tests/server/test_mount.py
@@ -1,6 +1,7 @@
 import contextlib
+import json
+from urllib.parse import quote
 
-import pytest
 from mcp.types import TextContent
 
 from fastmcp.server.server import FastMCP
@@ -190,7 +191,6 @@ async def test_mount_multiple_prompts():
     assert "sql_explain_sql" in main_app._prompt_manager._prompts
 
 
-@pytest.mark.anyio
 async def test_mount_lifespan():
     """Test that the lifespan of a mounted app is properly handled."""
     # Create apps
@@ -346,3 +346,83 @@ async def test_mount_with_proxy_tools():
     result = await main_app.call_tool("api_get_data", {"query": "test"})
     assert isinstance(result[0], TextContent)
     assert result[0].text == "Data for query: test"
+
+
+async def test_mount_with_proxy_prompts():
+    """
+    Test mounting with prompts that have custom keys.
+
+    This tests that the prompt's name doesn't change even though the registered
+    key does, which is important for correct rendering.
+    """
+    # Create apps
+    main_app = FastMCP("MainApp")
+    api_app = FastMCP("APIApp")
+
+    @api_app.prompt()
+    def greeting(name: str) -> str:
+        return f"Hello, {name} from API!"
+
+    main_app.mount("api", await FastMCP.as_proxy(api_app))
+
+    result = await main_app.get_prompt("api_greeting", {"name": "World"})
+    assert len(result) > 0
+    assert isinstance(result[0].content, TextContent)
+    assert result[0].content.text == "Hello, World from API!"
+
+
+async def test_mount_with_proxy_resources():
+    """
+    Test mounting with resources that have custom keys.
+
+    This tests that the resource's name doesn't change even though the registered
+    key does, which is important for correct access.
+    """
+    # Create apps
+    main_app = FastMCP("MainApp")
+    api_app = FastMCP("APIApp")
+
+    # Create a resource in the API app
+    @api_app.resource(uri="config://settings")
+    def get_config():
+        return {
+            "api_key": "12345",
+            "base_url": "https://api.example.com",
+        }
+
+    main_app.mount("api", await FastMCP.as_proxy(api_app))
+
+    # Access the resource through the main app with the prefixed key
+    resource = await main_app.read_resource("api+config://settings")
+    assert resource is not None
+    resource = json.loads(resource)
+    assert resource["api_key"] == "12345"
+    assert resource["base_url"] == "https://api.example.com"
+
+
+async def test_mount_with_proxy_resource_templates():
+    """
+    Test mounting with resource templates that have custom keys.
+
+    This tests that the template's name doesn't change even though the registered
+    key does, which is important for correct instantiation.
+    """
+    # Create apps
+    main_app = FastMCP("MainApp")
+    api_app = FastMCP("APIApp")
+
+    # Create a resource template in the API app
+    @api_app.resource(uri="user://{name}/{email}")
+    def create_user(name: str, email: str):
+        return {"name": name, "email": email}
+
+    main_app.mount("api", await FastMCP.as_proxy(api_app))
+
+    # Instantiate the template through the main app with the prefixed key
+    quoted_name = quote("John Doe", safe="")
+    quoted_email = quote("john@example.com", safe="")
+    user = await main_app.read_resource(f"api+user://{quoted_name}/{quoted_email}")
+    assert user is not None
+    user = json.loads(user)
+    assert user["name"] == "John Doe"
+    assert user["email"] == "john@example.com"