From fa3e64b9afdb30414deb356375b320d469bbe574 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:18:31 -0400 Subject: [PATCH 1/3] Create MCP prompts on object --- src/fastmcp/prompts/prompt.py | 19 ++++++++++ src/fastmcp/prompts/prompt_manager.py | 51 +++++++++++++++------------ src/fastmcp/server/server.py | 22 ++---------- src/fastmcp/tools/tool_manager.py | 12 +++---- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 06cc23301..5733b3705 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 30f971589..ce423c731 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 + # Create prefixed key + key = f"{prefix}{name}" if prefix else name - new_prompt = copy.copy(prompt) - new_prompt.name = prefixed_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/server/server.py b/src/fastmcp/server/server.py index b99e5d867..d1c04087d 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 9e0f1ac42..f11d98e22 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}"') From 4de2469d29f9cbcb9165054ebcf4b04efba8cea8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:31:10 -0400 Subject: [PATCH 2/3] Improve matching logic for quoted chars --- src/fastmcp/resources/template.py | 5 ++++- tests/resources/test_resource_template.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 08f39585c..bba1c4467 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/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 4f6638ed8..68b408aab 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), From 67fbb4c3e8c4da0b9f465f38629d48157723276d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:54:58 -0400 Subject: [PATCH 3/3] Ensure quoted URIs work; fix proxy template issue --- src/fastmcp/server/proxy.py | 11 ++- tests/resources/test_resource_template.py | 8 +++ tests/server/test_mount.py | 84 ++++++++++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 414dcba94..887e60efc 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/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 68b408aab..301a48297 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -374,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 5b3c79946..e66e65c1b 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"