Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
cc20abf
1
Parent(s): 1d65837
Add support for prompts
Browse files- examples/echo.py +12 -1
- examples/simple_echo.py +19 -0
- pyproject.toml +2 -2
- src/fastmcp/prompts/__init__.py +4 -0
- src/fastmcp/prompts/base.py +149 -0
- src/fastmcp/prompts/manager.py +50 -0
- src/fastmcp/prompts/prompt_manager.py +36 -0
- src/fastmcp/server.py +127 -51
- tests/prompts/test_base.py +193 -0
- tests/prompts/test_manager.py +107 -0
- tests/test_server.py +139 -13
- uv.lock +3 -3
examples/echo.py
CHANGED
|
@@ -10,10 +10,21 @@ mcp = FastMCP("Echo Server")
|
|
| 10 |
|
| 11 |
|
| 12 |
@mcp.tool()
|
| 13 |
-
def
|
| 14 |
"""Echo the input text"""
|
| 15 |
return text
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
if __name__ == "__main__":
|
| 19 |
mcp.run()
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
@mcp.tool()
|
| 13 |
+
def echo_tool(text: str) -> str:
|
| 14 |
"""Echo the input text"""
|
| 15 |
return text
|
| 16 |
|
| 17 |
|
| 18 |
+
@mcp.resource("echo://{text}")
|
| 19 |
+
def echo_resource(text: str) -> str:
|
| 20 |
+
"""Echo the input text"""
|
| 21 |
+
return f"Echo: {text}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@mcp.prompt("echo")
|
| 25 |
+
def echo_prompt(text: str) -> str:
|
| 26 |
+
return text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
if __name__ == "__main__":
|
| 30 |
mcp.run()
|
examples/simple_echo.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastMCP Echo Server
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastmcp import FastMCP
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# Create server
|
| 9 |
+
mcp = FastMCP("Echo Server")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@mcp.tool()
|
| 13 |
+
def echo(text: str) -> str:
|
| 14 |
+
"""Echo the input text"""
|
| 15 |
+
return text
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
mcp.run()
|
pyproject.toml
CHANGED
|
@@ -5,9 +5,9 @@ description = "A more ergonomic interface for MCP servers"
|
|
| 5 |
authors = [{ name = "Jeremiah Lowin" }]
|
| 6 |
dependencies = [
|
| 7 |
"httpx>=0.26.0",
|
| 8 |
-
"mcp>=1.0.0",
|
| 9 |
"pydantic-settings>=2.6.1",
|
| 10 |
-
"pydantic>=2.5.3",
|
| 11 |
"typer>=0.9.0",
|
| 12 |
]
|
| 13 |
requires-python = ">=3.10"
|
|
|
|
| 5 |
authors = [{ name = "Jeremiah Lowin" }]
|
| 6 |
dependencies = [
|
| 7 |
"httpx>=0.26.0",
|
| 8 |
+
"mcp>=1.0.0,<2.0.0",
|
| 9 |
"pydantic-settings>=2.6.1",
|
| 10 |
+
"pydantic>=2.5.3,<3.0.0",
|
| 11 |
"typer>=0.9.0",
|
| 12 |
]
|
| 13 |
requires-python = ">=3.10"
|
src/fastmcp/prompts/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import Prompt
|
| 2 |
+
from .manager import PromptManager
|
| 3 |
+
|
| 4 |
+
__all__ = ["Prompt", "PromptManager"]
|
src/fastmcp/prompts/base.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base classes for FastMCP prompts."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
|
| 5 |
+
import inspect
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call
|
| 8 |
+
from mcp.types import TextContent, ImageContent, EmbeddedResource
|
| 9 |
+
import pydantic_core
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Message(BaseModel):
|
| 13 |
+
"""Base class for all prompt messages."""
|
| 14 |
+
|
| 15 |
+
role: Literal["user", "assistant"]
|
| 16 |
+
content: Union[TextContent, ImageContent, EmbeddedResource]
|
| 17 |
+
|
| 18 |
+
def __init__(self, content, **kwargs):
|
| 19 |
+
super().__init__(content=content, **kwargs)
|
| 20 |
+
|
| 21 |
+
@field_validator("content", mode="before")
|
| 22 |
+
def validate_content(cls, v):
|
| 23 |
+
if isinstance(v, str):
|
| 24 |
+
return TextContent(type="text", text=v)
|
| 25 |
+
return v
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class UserMessage(Message):
|
| 29 |
+
"""A message from the user."""
|
| 30 |
+
|
| 31 |
+
role: Literal["user"] = "user"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AssistantMessage(Message):
|
| 35 |
+
"""A message from the assistant."""
|
| 36 |
+
|
| 37 |
+
role: Literal["assistant"] = "assistant"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
message_validator = TypeAdapter(Union[UserMessage, AssistantMessage])
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class PromptArgument(BaseModel):
|
| 44 |
+
"""An argument that can be passed to a prompt."""
|
| 45 |
+
|
| 46 |
+
name: str = Field(description="Name of the argument")
|
| 47 |
+
description: str | None = Field(
|
| 48 |
+
None, description="Description of what the argument does"
|
| 49 |
+
)
|
| 50 |
+
required: bool = Field(
|
| 51 |
+
default=False, description="Whether the argument is required"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class Prompt(BaseModel):
|
| 56 |
+
"""A prompt template that can be rendered with parameters."""
|
| 57 |
+
|
| 58 |
+
name: str = Field(description="Name of the prompt")
|
| 59 |
+
description: str | None = Field(
|
| 60 |
+
None, description="Description of what the prompt does"
|
| 61 |
+
)
|
| 62 |
+
arguments: list[PromptArgument] | None = Field(
|
| 63 |
+
None, description="Arguments that can be passed to the prompt"
|
| 64 |
+
)
|
| 65 |
+
fn: Callable = Field(exclude=True)
|
| 66 |
+
|
| 67 |
+
@classmethod
|
| 68 |
+
def from_function(
|
| 69 |
+
cls,
|
| 70 |
+
fn: Callable[..., Sequence[Message]],
|
| 71 |
+
name: Optional[str] = None,
|
| 72 |
+
description: Optional[str] = None,
|
| 73 |
+
) -> "Prompt":
|
| 74 |
+
"""Create a Prompt from a function."""
|
| 75 |
+
func_name = name or fn.__name__
|
| 76 |
+
|
| 77 |
+
if func_name == "<lambda>":
|
| 78 |
+
raise ValueError("You must provide a name for lambda functions")
|
| 79 |
+
|
| 80 |
+
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 81 |
+
parameters = TypeAdapter(fn).json_schema()
|
| 82 |
+
|
| 83 |
+
# Convert parameters to PromptArguments
|
| 84 |
+
arguments = []
|
| 85 |
+
if "properties" in parameters:
|
| 86 |
+
for param_name, param in parameters["properties"].items():
|
| 87 |
+
required = param_name in parameters.get("required", [])
|
| 88 |
+
arguments.append(
|
| 89 |
+
PromptArgument(
|
| 90 |
+
name=param_name,
|
| 91 |
+
description=param.get("description"),
|
| 92 |
+
required=required,
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# ensure the arguments are properly cast
|
| 97 |
+
fn = validate_call(fn)
|
| 98 |
+
|
| 99 |
+
return cls(
|
| 100 |
+
name=func_name,
|
| 101 |
+
description=description or fn.__doc__ or "",
|
| 102 |
+
arguments=arguments,
|
| 103 |
+
fn=fn,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
async def render(self, arguments: Optional[Dict[str, Any]] = None) -> list[Message]:
|
| 107 |
+
"""Render the prompt with arguments."""
|
| 108 |
+
# Validate required arguments
|
| 109 |
+
if self.arguments:
|
| 110 |
+
required = {arg.name for arg in self.arguments if arg.required}
|
| 111 |
+
provided = set(arguments or {})
|
| 112 |
+
missing = required - provided
|
| 113 |
+
if missing:
|
| 114 |
+
raise ValueError(f"Missing required arguments: {missing}")
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
# Call function and check if result is a coroutine
|
| 118 |
+
result = self.fn(**(arguments or {}))
|
| 119 |
+
if inspect.iscoroutine(result):
|
| 120 |
+
result = await result
|
| 121 |
+
|
| 122 |
+
# Validate messages
|
| 123 |
+
if not isinstance(result, (list, tuple)):
|
| 124 |
+
result = [result]
|
| 125 |
+
|
| 126 |
+
# Convert result to messages
|
| 127 |
+
messages = []
|
| 128 |
+
for msg in result:
|
| 129 |
+
try:
|
| 130 |
+
if isinstance(msg, Message):
|
| 131 |
+
messages.append(msg)
|
| 132 |
+
elif isinstance(msg, dict):
|
| 133 |
+
msg = message_validator.validate_python(msg)
|
| 134 |
+
messages.append(msg)
|
| 135 |
+
elif isinstance(msg, str):
|
| 136 |
+
messages.append(
|
| 137 |
+
UserMessage(content=TextContent(type="text", text=msg))
|
| 138 |
+
)
|
| 139 |
+
else:
|
| 140 |
+
msg = json.dumps(pydantic_core.to_jsonable_python(msg))
|
| 141 |
+
messages.append(Message(role="user", content=msg))
|
| 142 |
+
except Exception:
|
| 143 |
+
raise ValueError(
|
| 144 |
+
f"Could not convert prompt result to message: {msg}"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return messages
|
| 148 |
+
except Exception as e:
|
| 149 |
+
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
src/fastmcp/prompts/manager.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt management functionality."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, Optional
|
| 4 |
+
|
| 5 |
+
from fastmcp.prompts.base import Message, Prompt
|
| 6 |
+
from fastmcp.utilities.logging import get_logger
|
| 7 |
+
|
| 8 |
+
logger = get_logger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PromptManager:
|
| 12 |
+
"""Manages FastMCP prompts."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, warn_on_duplicate_prompts: bool = True):
|
| 15 |
+
self._prompts: Dict[str, Prompt] = {}
|
| 16 |
+
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
|
| 17 |
+
|
| 18 |
+
def get_prompt(self, name: str) -> Optional[Prompt]:
|
| 19 |
+
"""Get prompt by name."""
|
| 20 |
+
return self._prompts.get(name)
|
| 21 |
+
|
| 22 |
+
def list_prompts(self) -> list[Prompt]:
|
| 23 |
+
"""List all registered prompts."""
|
| 24 |
+
return list(self._prompts.values())
|
| 25 |
+
|
| 26 |
+
def add_prompt(
|
| 27 |
+
self,
|
| 28 |
+
prompt: Prompt,
|
| 29 |
+
) -> Prompt:
|
| 30 |
+
"""Add a prompt to the manager."""
|
| 31 |
+
|
| 32 |
+
# Check for duplicates
|
| 33 |
+
existing = self._prompts.get(prompt.name)
|
| 34 |
+
if existing:
|
| 35 |
+
if self.warn_on_duplicate_prompts:
|
| 36 |
+
logger.warning(f"Prompt already exists: {prompt.name}")
|
| 37 |
+
return existing
|
| 38 |
+
|
| 39 |
+
self._prompts[prompt.name] = prompt
|
| 40 |
+
return prompt
|
| 41 |
+
|
| 42 |
+
async def render_prompt(
|
| 43 |
+
self, name: str, arguments: Optional[Dict[str, Any]] = None
|
| 44 |
+
) -> list[Message]:
|
| 45 |
+
"""Render a prompt by name with arguments."""
|
| 46 |
+
prompt = self.get_prompt(name)
|
| 47 |
+
if not prompt:
|
| 48 |
+
raise ValueError(f"Unknown prompt: {name}")
|
| 49 |
+
|
| 50 |
+
return await prompt.render(arguments)
|
src/fastmcp/prompts/prompt_manager.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt management functionality."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
from fastmcp.prompts.base import Prompt
|
| 7 |
+
from fastmcp.utilities.logging import get_logger
|
| 8 |
+
|
| 9 |
+
logger = get_logger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PromptManager:
|
| 13 |
+
"""Manages FastMCP prompts."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, warn_on_duplicate_prompts: bool = True):
|
| 16 |
+
self._prompts: Dict[str, Prompt] = {}
|
| 17 |
+
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
|
| 18 |
+
|
| 19 |
+
def add_prompt(self, prompt: Prompt) -> Prompt:
|
| 20 |
+
"""Add a prompt to the manager."""
|
| 21 |
+
logger.debug(f"Adding prompt: {prompt.name}")
|
| 22 |
+
existing = self._prompts.get(prompt.name)
|
| 23 |
+
if existing:
|
| 24 |
+
if self.warn_on_duplicate_prompts:
|
| 25 |
+
logger.warning(f"Prompt already exists: {prompt.name}")
|
| 26 |
+
return existing
|
| 27 |
+
self._prompts[prompt.name] = prompt
|
| 28 |
+
return prompt
|
| 29 |
+
|
| 30 |
+
def get_prompt(self, name: str) -> Optional[Prompt]:
|
| 31 |
+
"""Get prompt by name."""
|
| 32 |
+
return self._prompts.get(name)
|
| 33 |
+
|
| 34 |
+
def list_prompts(self) -> list[Prompt]:
|
| 35 |
+
"""List all registered prompts."""
|
| 36 |
+
return list(self._prompts.values())
|
src/fastmcp/server.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
import pydantic_core
|
| 4 |
-
from typing import Any, Literal, Optional, Union
|
| 5 |
|
| 6 |
from mcp.server import RequestContext
|
| 7 |
from pydantic import BaseModel
|
|
@@ -24,6 +24,9 @@ from mcp.types import (
|
|
| 24 |
ResourceTemplate as MCPResourceTemplate,
|
| 25 |
TextContent,
|
| 26 |
ImageContent,
|
|
|
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
from pydantic_settings import BaseSettings
|
| 29 |
from pydantic.networks import _BaseUrl
|
|
@@ -33,6 +36,8 @@ from fastmcp.resources import Resource, ResourceManager, FunctionResource
|
|
| 33 |
from fastmcp.tools import ToolManager
|
| 34 |
from fastmcp.utilities.logging import configure_logging
|
| 35 |
from fastmcp.utilities.types import Image
|
|
|
|
|
|
|
| 36 |
|
| 37 |
logger = get_logger(__name__)
|
| 38 |
|
|
@@ -60,6 +65,9 @@ class Settings(BaseSettings):
|
|
| 60 |
# tool settings
|
| 61 |
warn_on_duplicate_tools: bool = True
|
| 62 |
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
class FastMCP:
|
| 65 |
def __init__(self, name=None, **settings: Optional[Settings]):
|
|
@@ -71,6 +79,9 @@ class FastMCP:
|
|
| 71 |
self._resource_manager = ResourceManager(
|
| 72 |
warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
|
| 73 |
)
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# Set up MCP protocol handlers
|
| 76 |
self._setup_handlers()
|
|
@@ -103,6 +114,8 @@ class FastMCP:
|
|
| 103 |
self._mcp_server.call_tool()(self.call_tool)
|
| 104 |
self._mcp_server.list_resources()(self.list_resources)
|
| 105 |
self._mcp_server.read_resource()(self.read_resource)
|
|
|
|
|
|
|
| 106 |
# TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10
|
| 107 |
# self._mcp_server.list_resource_templates()(self.list_resource_templates)
|
| 108 |
|
|
@@ -133,21 +146,10 @@ class FastMCP:
|
|
| 133 |
self, name: str, arguments: dict
|
| 134 |
) -> Sequence[Union[TextContent, ImageContent]]:
|
| 135 |
"""Call a tool by name with arguments."""
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
)
|
| 141 |
-
return _convert_to_content(result)
|
| 142 |
-
except Exception as e:
|
| 143 |
-
logger.error(f"Error calling tool {name}: {e}")
|
| 144 |
-
return [
|
| 145 |
-
TextContent(
|
| 146 |
-
type="text",
|
| 147 |
-
text=str(e),
|
| 148 |
-
is_error=True,
|
| 149 |
-
)
|
| 150 |
-
]
|
| 151 |
|
| 152 |
async def list_resources(self) -> list[MCPResource]:
|
| 153 |
"""List all available resources."""
|
|
@@ -335,6 +337,64 @@ class FastMCP:
|
|
| 335 |
|
| 336 |
return decorator
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
async def run_stdio_async(self) -> None:
|
| 339 |
"""Run the server using stdio transport."""
|
| 340 |
async with stdio_server() as (read_stream, write_stream):
|
|
@@ -381,45 +441,61 @@ class FastMCP:
|
|
| 381 |
log_level=self.settings.log_level,
|
| 382 |
)
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
|
| 385 |
-
def
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
return value
|
| 392 |
-
# Handle mixed content including Image objects
|
| 393 |
-
result = []
|
| 394 |
-
for item in value:
|
| 395 |
-
if isinstance(item, (TextContent, ImageContent)):
|
| 396 |
-
result.append(item)
|
| 397 |
-
elif isinstance(item, Image):
|
| 398 |
-
result.append(item.to_image_content())
|
| 399 |
-
else:
|
| 400 |
-
result.append(
|
| 401 |
-
TextContent(
|
| 402 |
-
type="text",
|
| 403 |
-
text=json.dumps(pydantic_core.to_jsonable_python(item)),
|
| 404 |
-
)
|
| 405 |
-
)
|
| 406 |
-
return result
|
| 407 |
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
|
|
|
| 411 |
|
| 412 |
-
# Image helper
|
| 413 |
-
if isinstance(value, Image):
|
| 414 |
-
return [value.to_image_content()]
|
| 415 |
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
|
| 424 |
|
| 425 |
class Context(BaseModel):
|
|
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
import pydantic_core
|
| 4 |
+
from typing import Any, Literal, Optional, Union, Dict
|
| 5 |
|
| 6 |
from mcp.server import RequestContext
|
| 7 |
from pydantic import BaseModel
|
|
|
|
| 24 |
ResourceTemplate as MCPResourceTemplate,
|
| 25 |
TextContent,
|
| 26 |
ImageContent,
|
| 27 |
+
EmbeddedResource,
|
| 28 |
+
Prompt as MCPPrompt,
|
| 29 |
+
GetPromptResult,
|
| 30 |
)
|
| 31 |
from pydantic_settings import BaseSettings
|
| 32 |
from pydantic.networks import _BaseUrl
|
|
|
|
| 36 |
from fastmcp.tools import ToolManager
|
| 37 |
from fastmcp.utilities.logging import configure_logging
|
| 38 |
from fastmcp.utilities.types import Image
|
| 39 |
+
from fastmcp.prompts import Prompt, PromptManager
|
| 40 |
+
from itertools import chain
|
| 41 |
|
| 42 |
logger = get_logger(__name__)
|
| 43 |
|
|
|
|
| 65 |
# tool settings
|
| 66 |
warn_on_duplicate_tools: bool = True
|
| 67 |
|
| 68 |
+
# prompt settings
|
| 69 |
+
warn_on_duplicate_prompts: bool = True
|
| 70 |
+
|
| 71 |
|
| 72 |
class FastMCP:
|
| 73 |
def __init__(self, name=None, **settings: Optional[Settings]):
|
|
|
|
| 79 |
self._resource_manager = ResourceManager(
|
| 80 |
warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
|
| 81 |
)
|
| 82 |
+
self._prompt_manager = PromptManager(
|
| 83 |
+
warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
|
| 84 |
+
)
|
| 85 |
|
| 86 |
# Set up MCP protocol handlers
|
| 87 |
self._setup_handlers()
|
|
|
|
| 114 |
self._mcp_server.call_tool()(self.call_tool)
|
| 115 |
self._mcp_server.list_resources()(self.list_resources)
|
| 116 |
self._mcp_server.read_resource()(self.read_resource)
|
| 117 |
+
self._mcp_server.list_prompts()(self.list_prompts)
|
| 118 |
+
self._mcp_server.get_prompt()(self.get_prompt)
|
| 119 |
# TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10
|
| 120 |
# self._mcp_server.list_resource_templates()(self.list_resource_templates)
|
| 121 |
|
|
|
|
| 146 |
self, name: str, arguments: dict
|
| 147 |
) -> Sequence[Union[TextContent, ImageContent]]:
|
| 148 |
"""Call a tool by name with arguments."""
|
| 149 |
+
context = self.get_context()
|
| 150 |
+
result = await self._tool_manager.call_tool(name, arguments, context=context)
|
| 151 |
+
converted_result = _convert_to_content(result)
|
| 152 |
+
return converted_result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
async def list_resources(self) -> list[MCPResource]:
|
| 155 |
"""List all available resources."""
|
|
|
|
| 337 |
|
| 338 |
return decorator
|
| 339 |
|
| 340 |
+
def add_prompt(self, prompt: Prompt) -> None:
|
| 341 |
+
"""Add a prompt to the server.
|
| 342 |
+
|
| 343 |
+
Args:
|
| 344 |
+
prompt: A Prompt instance to add
|
| 345 |
+
"""
|
| 346 |
+
self._prompt_manager.add_prompt(prompt)
|
| 347 |
+
|
| 348 |
+
def prompt(
|
| 349 |
+
self, name: Optional[str] = None, description: Optional[str] = None
|
| 350 |
+
) -> Callable:
|
| 351 |
+
"""Decorator to register a prompt.
|
| 352 |
+
|
| 353 |
+
Args:
|
| 354 |
+
name: Optional name for the prompt (defaults to function name)
|
| 355 |
+
description: Optional description of what the prompt does
|
| 356 |
+
|
| 357 |
+
Example:
|
| 358 |
+
@server.prompt()
|
| 359 |
+
def analyze_table(table_name: str) -> list[Message]:
|
| 360 |
+
schema = read_table_schema(table_name)
|
| 361 |
+
return [
|
| 362 |
+
{
|
| 363 |
+
"role": "user",
|
| 364 |
+
"content": f"Analyze this schema:\n{schema}"
|
| 365 |
+
}
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
@server.prompt()
|
| 369 |
+
async def analyze_file(path: str) -> list[Message]:
|
| 370 |
+
content = await read_file(path)
|
| 371 |
+
return [
|
| 372 |
+
{
|
| 373 |
+
"role": "user",
|
| 374 |
+
"content": {
|
| 375 |
+
"type": "resource",
|
| 376 |
+
"resource": {
|
| 377 |
+
"uri": f"file://{path}",
|
| 378 |
+
"text": content
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
]
|
| 383 |
+
"""
|
| 384 |
+
# Check if user passed function directly instead of calling decorator
|
| 385 |
+
if callable(name):
|
| 386 |
+
raise TypeError(
|
| 387 |
+
"The @prompt decorator was used incorrectly. "
|
| 388 |
+
"Did you forget to call it? Use @prompt() instead of @prompt"
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
def decorator(func: Callable) -> Callable:
|
| 392 |
+
prompt = Prompt.from_function(func, name=name, description=description)
|
| 393 |
+
self.add_prompt(prompt)
|
| 394 |
+
return func
|
| 395 |
+
|
| 396 |
+
return decorator
|
| 397 |
+
|
| 398 |
async def run_stdio_async(self) -> None:
|
| 399 |
"""Run the server using stdio transport."""
|
| 400 |
async with stdio_server() as (read_stream, write_stream):
|
|
|
|
| 441 |
log_level=self.settings.log_level,
|
| 442 |
)
|
| 443 |
|
| 444 |
+
async def list_prompts(self) -> list[MCPPrompt]:
|
| 445 |
+
"""List all available prompts."""
|
| 446 |
+
prompts = self._prompt_manager.list_prompts()
|
| 447 |
+
return [
|
| 448 |
+
MCPPrompt(
|
| 449 |
+
name=prompt.name,
|
| 450 |
+
description=prompt.description,
|
| 451 |
+
arguments=[
|
| 452 |
+
{
|
| 453 |
+
"name": arg.name,
|
| 454 |
+
"description": arg.description,
|
| 455 |
+
"required": arg.required,
|
| 456 |
+
}
|
| 457 |
+
for arg in (prompt.arguments or [])
|
| 458 |
+
],
|
| 459 |
+
)
|
| 460 |
+
for prompt in prompts
|
| 461 |
+
]
|
| 462 |
|
| 463 |
+
async def get_prompt(
|
| 464 |
+
self, name: str, arguments: Optional[Dict[str, Any]] = None
|
| 465 |
+
) -> GetPromptResult:
|
| 466 |
+
"""Get a prompt by name with arguments."""
|
| 467 |
+
try:
|
| 468 |
+
messages = await self._prompt_manager.render_prompt(name, arguments)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
+
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
|
| 471 |
+
except Exception as e:
|
| 472 |
+
logger.error(f"Error getting prompt {name}: {e}")
|
| 473 |
+
raise ValueError(str(e))
|
| 474 |
|
|
|
|
|
|
|
|
|
|
| 475 |
|
| 476 |
+
def _convert_to_content(
|
| 477 |
+
result: Any,
|
| 478 |
+
) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
|
| 479 |
+
"""Convert a result to a sequence of content objects."""
|
| 480 |
+
if result is None:
|
| 481 |
+
return []
|
| 482 |
+
|
| 483 |
+
if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
|
| 484 |
+
return [result]
|
| 485 |
+
|
| 486 |
+
if isinstance(result, Image):
|
| 487 |
+
return [result.to_image_content()]
|
| 488 |
+
|
| 489 |
+
if isinstance(result, (list, tuple)):
|
| 490 |
+
return list(chain.from_iterable(_convert_to_content(item) for item in result))
|
| 491 |
+
|
| 492 |
+
if not isinstance(result, str):
|
| 493 |
+
try:
|
| 494 |
+
result = json.dumps(pydantic_core.to_jsonable_python(result))
|
| 495 |
+
except Exception:
|
| 496 |
+
result = str(result)
|
| 497 |
+
|
| 498 |
+
return [TextContent(type="text", text=result)]
|
| 499 |
|
| 500 |
|
| 501 |
class Context(BaseModel):
|
tests/prompts/test_base.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastmcp.prompts.base import (
|
| 3 |
+
Prompt,
|
| 4 |
+
UserMessage,
|
| 5 |
+
TextContent,
|
| 6 |
+
AssistantMessage,
|
| 7 |
+
Message,
|
| 8 |
+
)
|
| 9 |
+
from mcp.types import EmbeddedResource, TextResourceContents
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestRenderPrompt:
|
| 13 |
+
async def test_basic_fn(self):
|
| 14 |
+
def fn() -> str:
|
| 15 |
+
return "Hello, world!"
|
| 16 |
+
|
| 17 |
+
prompt = Prompt.from_function(fn)
|
| 18 |
+
assert await prompt.render() == [
|
| 19 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
async def test_async_fn(self):
|
| 23 |
+
async def fn() -> str:
|
| 24 |
+
return "Hello, world!"
|
| 25 |
+
|
| 26 |
+
prompt = Prompt.from_function(fn)
|
| 27 |
+
assert await prompt.render() == [
|
| 28 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
async def test_fn_with_args(self):
|
| 32 |
+
async def fn(name: str, age: int = 30) -> str:
|
| 33 |
+
return f"Hello, {name}! You're {age} years old."
|
| 34 |
+
|
| 35 |
+
prompt = Prompt.from_function(fn)
|
| 36 |
+
assert await prompt.render(arguments=dict(name="World")) == [
|
| 37 |
+
UserMessage(
|
| 38 |
+
content=TextContent(
|
| 39 |
+
type="text", text="Hello, World! You're 30 years old."
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
async def test_fn_with_invalid_kwargs(self):
|
| 45 |
+
async def fn(name: str, age: int = 30) -> str:
|
| 46 |
+
return f"Hello, {name}! You're {age} years old."
|
| 47 |
+
|
| 48 |
+
prompt = Prompt.from_function(fn)
|
| 49 |
+
with pytest.raises(ValueError):
|
| 50 |
+
await prompt.render(arguments=dict(age=40))
|
| 51 |
+
|
| 52 |
+
async def test_fn_returns_message(self):
|
| 53 |
+
async def fn() -> UserMessage:
|
| 54 |
+
return UserMessage(content="Hello, world!")
|
| 55 |
+
|
| 56 |
+
prompt = Prompt.from_function(fn)
|
| 57 |
+
assert await prompt.render() == [
|
| 58 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
async def test_fn_returns_assistant_message(self):
|
| 62 |
+
async def fn() -> AssistantMessage:
|
| 63 |
+
return AssistantMessage(
|
| 64 |
+
content=TextContent(type="text", text="Hello, world!")
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
prompt = Prompt.from_function(fn)
|
| 68 |
+
assert await prompt.render() == [
|
| 69 |
+
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
async def test_fn_returns_multiple_messages(self):
|
| 73 |
+
expected = [
|
| 74 |
+
UserMessage("Hello, world!"),
|
| 75 |
+
AssistantMessage("How can I help you today?"),
|
| 76 |
+
UserMessage("I'm looking for a restaurant in the center of town."),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
async def fn() -> list[Message]:
|
| 80 |
+
return expected
|
| 81 |
+
|
| 82 |
+
prompt = Prompt.from_function(fn)
|
| 83 |
+
assert await prompt.render() == expected
|
| 84 |
+
|
| 85 |
+
async def test_fn_returns_list_of_strings(self):
|
| 86 |
+
expected = [
|
| 87 |
+
"Hello, world!",
|
| 88 |
+
"I'm looking for a restaurant in the center of town.",
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
async def fn() -> list[str]:
|
| 92 |
+
return expected
|
| 93 |
+
|
| 94 |
+
prompt = Prompt.from_function(fn)
|
| 95 |
+
assert await prompt.render() == [UserMessage(t) for t in expected]
|
| 96 |
+
|
| 97 |
+
async def test_fn_returns_resource_content(self):
|
| 98 |
+
"""Test returning a message with resource content."""
|
| 99 |
+
|
| 100 |
+
async def fn() -> UserMessage:
|
| 101 |
+
return UserMessage(
|
| 102 |
+
content=EmbeddedResource(
|
| 103 |
+
type="resource",
|
| 104 |
+
resource=TextResourceContents(
|
| 105 |
+
uri="file://file.txt",
|
| 106 |
+
text="File contents",
|
| 107 |
+
mimeType="text/plain",
|
| 108 |
+
),
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
prompt = Prompt.from_function(fn)
|
| 113 |
+
assert await prompt.render() == [
|
| 114 |
+
UserMessage(
|
| 115 |
+
content=EmbeddedResource(
|
| 116 |
+
type="resource",
|
| 117 |
+
resource=TextResourceContents(
|
| 118 |
+
uri="file://file.txt",
|
| 119 |
+
text="File contents",
|
| 120 |
+
mimeType="text/plain",
|
| 121 |
+
),
|
| 122 |
+
)
|
| 123 |
+
)
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
async def test_fn_returns_mixed_content(self):
|
| 127 |
+
"""Test returning messages with mixed content types."""
|
| 128 |
+
|
| 129 |
+
async def fn() -> list[Message]:
|
| 130 |
+
return [
|
| 131 |
+
UserMessage(content="Please analyze this file:"),
|
| 132 |
+
UserMessage(
|
| 133 |
+
content=EmbeddedResource(
|
| 134 |
+
type="resource",
|
| 135 |
+
resource=TextResourceContents(
|
| 136 |
+
uri="file://file.txt",
|
| 137 |
+
text="File contents",
|
| 138 |
+
mimeType="text/plain",
|
| 139 |
+
),
|
| 140 |
+
)
|
| 141 |
+
),
|
| 142 |
+
AssistantMessage(content="I'll help analyze that file."),
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
prompt = Prompt.from_function(fn)
|
| 146 |
+
assert await prompt.render() == [
|
| 147 |
+
UserMessage(
|
| 148 |
+
content=TextContent(type="text", text="Please analyze this file:")
|
| 149 |
+
),
|
| 150 |
+
UserMessage(
|
| 151 |
+
content=EmbeddedResource(
|
| 152 |
+
type="resource",
|
| 153 |
+
resource=TextResourceContents(
|
| 154 |
+
uri="file://file.txt",
|
| 155 |
+
text="File contents",
|
| 156 |
+
mimeType="text/plain",
|
| 157 |
+
),
|
| 158 |
+
)
|
| 159 |
+
),
|
| 160 |
+
AssistantMessage(
|
| 161 |
+
content=TextContent(type="text", text="I'll help analyze that file.")
|
| 162 |
+
),
|
| 163 |
+
]
|
| 164 |
+
|
| 165 |
+
async def test_fn_returns_dict_with_resource(self):
|
| 166 |
+
"""Test returning a dict with resource content."""
|
| 167 |
+
|
| 168 |
+
async def fn() -> dict:
|
| 169 |
+
return {
|
| 170 |
+
"role": "user",
|
| 171 |
+
"content": {
|
| 172 |
+
"type": "resource",
|
| 173 |
+
"resource": {
|
| 174 |
+
"uri": "file://file.txt",
|
| 175 |
+
"text": "File contents",
|
| 176 |
+
"mimeType": "text/plain",
|
| 177 |
+
},
|
| 178 |
+
},
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
prompt = Prompt.from_function(fn)
|
| 182 |
+
assert await prompt.render() == [
|
| 183 |
+
UserMessage(
|
| 184 |
+
content=EmbeddedResource(
|
| 185 |
+
type="resource",
|
| 186 |
+
resource=TextResourceContents(
|
| 187 |
+
uri="file://file.txt",
|
| 188 |
+
text="File contents",
|
| 189 |
+
mimeType="text/plain",
|
| 190 |
+
),
|
| 191 |
+
)
|
| 192 |
+
)
|
| 193 |
+
]
|
tests/prompts/test_manager.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastmcp.prompts.base import UserMessage, TextContent, Prompt
|
| 3 |
+
from fastmcp.prompts.manager import PromptManager
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestPromptManager:
|
| 7 |
+
def test_add_prompt(self):
|
| 8 |
+
"""Test adding a prompt to the manager."""
|
| 9 |
+
|
| 10 |
+
def fn() -> str:
|
| 11 |
+
return "Hello, world!"
|
| 12 |
+
|
| 13 |
+
manager = PromptManager()
|
| 14 |
+
prompt = Prompt.from_function(fn)
|
| 15 |
+
added = manager.add_prompt(prompt)
|
| 16 |
+
assert added == prompt
|
| 17 |
+
assert manager.get_prompt("fn") == prompt
|
| 18 |
+
|
| 19 |
+
def test_add_duplicate_prompt(self, caplog):
|
| 20 |
+
"""Test adding the same prompt twice."""
|
| 21 |
+
|
| 22 |
+
def fn() -> str:
|
| 23 |
+
return "Hello, world!"
|
| 24 |
+
|
| 25 |
+
manager = PromptManager()
|
| 26 |
+
prompt = Prompt.from_function(fn)
|
| 27 |
+
first = manager.add_prompt(prompt)
|
| 28 |
+
second = manager.add_prompt(prompt)
|
| 29 |
+
assert first == second
|
| 30 |
+
assert "Prompt already exists" in caplog.text
|
| 31 |
+
|
| 32 |
+
def test_disable_warn_on_duplicate_prompts(self, caplog):
|
| 33 |
+
"""Test disabling warning on duplicate prompts."""
|
| 34 |
+
|
| 35 |
+
def fn() -> str:
|
| 36 |
+
return "Hello, world!"
|
| 37 |
+
|
| 38 |
+
manager = PromptManager(warn_on_duplicate_prompts=False)
|
| 39 |
+
prompt = Prompt.from_function(fn)
|
| 40 |
+
first = manager.add_prompt(prompt)
|
| 41 |
+
second = manager.add_prompt(prompt)
|
| 42 |
+
assert first == second
|
| 43 |
+
assert "Prompt already exists" not in caplog.text
|
| 44 |
+
|
| 45 |
+
def test_list_prompts(self):
|
| 46 |
+
"""Test listing all prompts."""
|
| 47 |
+
|
| 48 |
+
def fn1() -> str:
|
| 49 |
+
return "Hello, world!"
|
| 50 |
+
|
| 51 |
+
def fn2() -> str:
|
| 52 |
+
return "Goodbye, world!"
|
| 53 |
+
|
| 54 |
+
manager = PromptManager()
|
| 55 |
+
prompt1 = Prompt.from_function(fn1)
|
| 56 |
+
prompt2 = Prompt.from_function(fn2)
|
| 57 |
+
manager.add_prompt(prompt1)
|
| 58 |
+
manager.add_prompt(prompt2)
|
| 59 |
+
prompts = manager.list_prompts()
|
| 60 |
+
assert len(prompts) == 2
|
| 61 |
+
assert prompts == [prompt1, prompt2]
|
| 62 |
+
|
| 63 |
+
async def test_render_prompt(self):
|
| 64 |
+
"""Test rendering a prompt."""
|
| 65 |
+
|
| 66 |
+
def fn() -> str:
|
| 67 |
+
return "Hello, world!"
|
| 68 |
+
|
| 69 |
+
manager = PromptManager()
|
| 70 |
+
prompt = Prompt.from_function(fn)
|
| 71 |
+
manager.add_prompt(prompt)
|
| 72 |
+
messages = await manager.render_prompt("fn")
|
| 73 |
+
assert messages == [
|
| 74 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
async def test_render_prompt_with_args(self):
|
| 78 |
+
"""Test rendering a prompt with arguments."""
|
| 79 |
+
|
| 80 |
+
def fn(name: str) -> str:
|
| 81 |
+
return f"Hello, {name}!"
|
| 82 |
+
|
| 83 |
+
manager = PromptManager()
|
| 84 |
+
prompt = Prompt.from_function(fn)
|
| 85 |
+
manager.add_prompt(prompt)
|
| 86 |
+
messages = await manager.render_prompt("fn", arguments={"name": "World"})
|
| 87 |
+
assert messages == [
|
| 88 |
+
UserMessage(content=TextContent(type="text", text="Hello, World!"))
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
async def test_render_unknown_prompt(self):
|
| 92 |
+
"""Test rendering a non-existent prompt."""
|
| 93 |
+
manager = PromptManager()
|
| 94 |
+
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
|
| 95 |
+
await manager.render_prompt("unknown")
|
| 96 |
+
|
| 97 |
+
async def test_render_prompt_with_missing_args(self):
|
| 98 |
+
"""Test rendering a prompt with missing required arguments."""
|
| 99 |
+
|
| 100 |
+
def fn(name: str) -> str:
|
| 101 |
+
return f"Hello, {name}!"
|
| 102 |
+
|
| 103 |
+
manager = PromptManager()
|
| 104 |
+
prompt = Prompt.from_function(fn)
|
| 105 |
+
manager.add_prompt(prompt)
|
| 106 |
+
with pytest.raises(ValueError, match="Missing required arguments"):
|
| 107 |
+
await manager.render_prompt("fn")
|
tests/test_server.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
from mcp.shared.memory import (
|
| 2 |
create_connected_server_and_client_session as client_session,
|
| 3 |
)
|
|
|
|
| 4 |
from fastmcp import FastMCP, Context
|
| 5 |
from fastmcp.resources import FileResource, FunctionResource
|
| 6 |
from fastmcp.utilities.types import Image
|
| 7 |
from mcp.types import TextContent, ImageContent
|
|
|
|
| 8 |
import pytest
|
| 9 |
-
from pydantic import BaseModel
|
| 10 |
from pathlib import Path
|
| 11 |
import base64
|
| 12 |
from typing import Union, TYPE_CHECKING
|
|
@@ -67,11 +68,6 @@ def error_tool_fn() -> None:
|
|
| 67 |
raise ValueError("Test error")
|
| 68 |
|
| 69 |
|
| 70 |
-
class ErrorResponse(BaseModel):
|
| 71 |
-
is_error: bool = True
|
| 72 |
-
message: str
|
| 73 |
-
|
| 74 |
-
|
| 75 |
def image_tool_fn(path: str) -> Image:
|
| 76 |
return Image(path)
|
| 77 |
|
|
@@ -113,7 +109,7 @@ class TestServerTools:
|
|
| 113 |
assert len(result.content) == 1
|
| 114 |
assert result.content[0].type == "text"
|
| 115 |
assert "Test error" in result.content[0].text
|
| 116 |
-
assert result.
|
| 117 |
|
| 118 |
async def test_tool_exception_content(self):
|
| 119 |
"""Test that exception details are properly formatted in the response"""
|
|
@@ -121,11 +117,10 @@ class TestServerTools:
|
|
| 121 |
mcp.add_tool(error_tool_fn)
|
| 122 |
async with client_session(mcp._mcp_server) as client:
|
| 123 |
result = await client.call_tool("error_tool_fn", {})
|
| 124 |
-
|
| 125 |
-
assert content.
|
| 126 |
-
assert
|
| 127 |
-
assert
|
| 128 |
-
assert content.is_error is True
|
| 129 |
|
| 130 |
async def test_tool_text_conversion(self):
|
| 131 |
mcp = FastMCP()
|
|
@@ -185,7 +180,7 @@ class TestServerTools:
|
|
| 185 |
assert len(result.content) == 4
|
| 186 |
# Check text conversion
|
| 187 |
assert result.content[0].type == "text"
|
| 188 |
-
assert
|
| 189 |
# Check image conversion
|
| 190 |
assert result.content[1].type == "image"
|
| 191 |
assert result.content[1].mimeType == "image/png"
|
|
@@ -463,3 +458,134 @@ class TestContextInjection:
|
|
| 463 |
result = await client.call_tool("tool_with_resource", {})
|
| 464 |
assert len(result.content) == 1
|
| 465 |
assert "Read resource: resource data" in result.content[0].text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from mcp.shared.memory import (
|
| 2 |
create_connected_server_and_client_session as client_session,
|
| 3 |
)
|
| 4 |
+
from mcp.shared.exceptions import McpError
|
| 5 |
from fastmcp import FastMCP, Context
|
| 6 |
from fastmcp.resources import FileResource, FunctionResource
|
| 7 |
from fastmcp.utilities.types import Image
|
| 8 |
from mcp.types import TextContent, ImageContent
|
| 9 |
+
from fastmcp.prompts.base import Message, UserMessage, TextContent, EmbeddedResource
|
| 10 |
import pytest
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
import base64
|
| 13 |
from typing import Union, TYPE_CHECKING
|
|
|
|
| 68 |
raise ValueError("Test error")
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def image_tool_fn(path: str) -> Image:
|
| 72 |
return Image(path)
|
| 73 |
|
|
|
|
| 109 |
assert len(result.content) == 1
|
| 110 |
assert result.content[0].type == "text"
|
| 111 |
assert "Test error" in result.content[0].text
|
| 112 |
+
assert result.isError is True
|
| 113 |
|
| 114 |
async def test_tool_exception_content(self):
|
| 115 |
"""Test that exception details are properly formatted in the response"""
|
|
|
|
| 117 |
mcp.add_tool(error_tool_fn)
|
| 118 |
async with client_session(mcp._mcp_server) as client:
|
| 119 |
result = await client.call_tool("error_tool_fn", {})
|
| 120 |
+
assert result.content[0].type == "text"
|
| 121 |
+
assert isinstance(result.content[0].text, str)
|
| 122 |
+
assert "Test error" in result.content[0].text
|
| 123 |
+
assert result.isError is True
|
|
|
|
| 124 |
|
| 125 |
async def test_tool_text_conversion(self):
|
| 126 |
mcp = FastMCP()
|
|
|
|
| 180 |
assert len(result.content) == 4
|
| 181 |
# Check text conversion
|
| 182 |
assert result.content[0].type == "text"
|
| 183 |
+
assert "text message" in result.content[0].text
|
| 184 |
# Check image conversion
|
| 185 |
assert result.content[1].type == "image"
|
| 186 |
assert result.content[1].mimeType == "image/png"
|
|
|
|
| 458 |
result = await client.call_tool("tool_with_resource", {})
|
| 459 |
assert len(result.content) == 1
|
| 460 |
assert "Read resource: resource data" in result.content[0].text
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
class TestServerPrompts:
|
| 464 |
+
"""Test prompt functionality in FastMCP server."""
|
| 465 |
+
|
| 466 |
+
async def test_prompt_decorator(self):
|
| 467 |
+
"""Test that the prompt decorator registers prompts correctly."""
|
| 468 |
+
mcp = FastMCP()
|
| 469 |
+
|
| 470 |
+
@mcp.prompt()
|
| 471 |
+
def fn() -> str:
|
| 472 |
+
return "Hello, world!"
|
| 473 |
+
|
| 474 |
+
prompts = mcp._prompt_manager.list_prompts()
|
| 475 |
+
assert len(prompts) == 1
|
| 476 |
+
assert prompts[0].name == "fn"
|
| 477 |
+
# Don't compare functions directly since validate_call wraps them
|
| 478 |
+
assert await prompts[0].render() == [
|
| 479 |
+
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
| 480 |
+
]
|
| 481 |
+
|
| 482 |
+
def test_prompt_decorator_with_name(self):
|
| 483 |
+
"""Test prompt decorator with custom name."""
|
| 484 |
+
mcp = FastMCP()
|
| 485 |
+
|
| 486 |
+
@mcp.prompt(name="custom")
|
| 487 |
+
def fn() -> str:
|
| 488 |
+
return "Hello, world!"
|
| 489 |
+
|
| 490 |
+
prompts = mcp._prompt_manager.list_prompts()
|
| 491 |
+
assert len(prompts) == 1
|
| 492 |
+
assert prompts[0].name == "custom"
|
| 493 |
+
|
| 494 |
+
def test_prompt_decorator_with_description(self):
|
| 495 |
+
"""Test prompt decorator with custom description."""
|
| 496 |
+
mcp = FastMCP()
|
| 497 |
+
|
| 498 |
+
@mcp.prompt(description="A custom description")
|
| 499 |
+
def fn() -> str:
|
| 500 |
+
return "Hello, world!"
|
| 501 |
+
|
| 502 |
+
prompts = mcp._prompt_manager.list_prompts()
|
| 503 |
+
assert len(prompts) == 1
|
| 504 |
+
assert prompts[0].description == "A custom description"
|
| 505 |
+
|
| 506 |
+
def test_prompt_decorator_error(self):
|
| 507 |
+
"""Test error when decorator is used incorrectly."""
|
| 508 |
+
mcp = FastMCP()
|
| 509 |
+
with pytest.raises(TypeError, match="decorator was used incorrectly"):
|
| 510 |
+
|
| 511 |
+
@mcp.prompt
|
| 512 |
+
def fn() -> str:
|
| 513 |
+
return "Hello, world!"
|
| 514 |
+
|
| 515 |
+
async def test_list_prompts(self):
|
| 516 |
+
"""Test listing prompts through MCP protocol."""
|
| 517 |
+
mcp = FastMCP()
|
| 518 |
+
|
| 519 |
+
@mcp.prompt()
|
| 520 |
+
def fn(name: str, optional: str = "default") -> str:
|
| 521 |
+
return f"Hello, {name}!"
|
| 522 |
+
|
| 523 |
+
async with client_session(mcp._mcp_server) as client:
|
| 524 |
+
result = await client.list_prompts()
|
| 525 |
+
assert len(result.prompts) == 1
|
| 526 |
+
assert result.prompts[0].name == "fn"
|
| 527 |
+
assert len(result.prompts[0].arguments) == 2
|
| 528 |
+
assert result.prompts[0].arguments[0].name == "name"
|
| 529 |
+
assert result.prompts[0].arguments[0].required is True
|
| 530 |
+
assert result.prompts[0].arguments[1].name == "optional"
|
| 531 |
+
assert result.prompts[0].arguments[1].required is False
|
| 532 |
+
|
| 533 |
+
async def test_get_prompt(self):
|
| 534 |
+
"""Test getting a prompt through MCP protocol."""
|
| 535 |
+
mcp = FastMCP()
|
| 536 |
+
|
| 537 |
+
@mcp.prompt()
|
| 538 |
+
def fn(name: str) -> str:
|
| 539 |
+
return f"Hello, {name}!"
|
| 540 |
+
|
| 541 |
+
async with client_session(mcp._mcp_server) as client:
|
| 542 |
+
result = await client.get_prompt("fn", {"name": "World"})
|
| 543 |
+
assert len(result.messages) == 1
|
| 544 |
+
assert result.messages[0].role == "user"
|
| 545 |
+
assert result.messages[0].content.type == "text"
|
| 546 |
+
assert result.messages[0].content.text == "Hello, World!"
|
| 547 |
+
|
| 548 |
+
async def test_get_prompt_with_resource(self):
|
| 549 |
+
"""Test getting a prompt that returns resource content."""
|
| 550 |
+
mcp = FastMCP()
|
| 551 |
+
|
| 552 |
+
@mcp.prompt()
|
| 553 |
+
def fn() -> Message:
|
| 554 |
+
return UserMessage(
|
| 555 |
+
content=EmbeddedResource(
|
| 556 |
+
type="resource",
|
| 557 |
+
resource={
|
| 558 |
+
"uri": "file://test.txt",
|
| 559 |
+
"text": "File contents",
|
| 560 |
+
"mimeType": "text/plain",
|
| 561 |
+
},
|
| 562 |
+
)
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
async with client_session(mcp._mcp_server) as client:
|
| 566 |
+
result = await client.get_prompt("fn")
|
| 567 |
+
assert len(result.messages) == 1
|
| 568 |
+
assert result.messages[0].role == "user"
|
| 569 |
+
assert result.messages[0].content.type == "resource"
|
| 570 |
+
assert str(result.messages[0].content.resource.uri) == "file://test.txt/"
|
| 571 |
+
assert result.messages[0].content.resource.text == "File contents"
|
| 572 |
+
assert result.messages[0].content.resource.mimeType == "text/plain"
|
| 573 |
+
|
| 574 |
+
async def test_get_unknown_prompt(self):
|
| 575 |
+
"""Test error when getting unknown prompt."""
|
| 576 |
+
mcp = FastMCP()
|
| 577 |
+
async with client_session(mcp._mcp_server) as client:
|
| 578 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 579 |
+
await client.get_prompt("unknown")
|
| 580 |
+
|
| 581 |
+
async def test_get_prompt_missing_args(self):
|
| 582 |
+
"""Test error when required arguments are missing."""
|
| 583 |
+
mcp = FastMCP()
|
| 584 |
+
|
| 585 |
+
@mcp.prompt()
|
| 586 |
+
def fn(name: str) -> str:
|
| 587 |
+
return f"Hello, {name}!"
|
| 588 |
+
|
| 589 |
+
async with client_session(mcp._mcp_server) as client:
|
| 590 |
+
with pytest.raises(McpError, match="Missing required arguments"):
|
| 591 |
+
await client.get_prompt("fn")
|
uv.lock
CHANGED
|
@@ -231,7 +231,7 @@ wheels = [
|
|
| 231 |
|
| 232 |
[[package]]
|
| 233 |
name = "fastmcp"
|
| 234 |
-
version = "0.2.1.
|
| 235 |
source = { editable = "." }
|
| 236 |
dependencies = [
|
| 237 |
{ name = "httpx" },
|
|
@@ -254,8 +254,8 @@ dev = [
|
|
| 254 |
[package.metadata]
|
| 255 |
requires-dist = [
|
| 256 |
{ name = "httpx", specifier = ">=0.26.0" },
|
| 257 |
-
{ name = "mcp", specifier = ">=1.0.0" },
|
| 258 |
-
{ name = "pydantic", specifier = ">=2.5.3" },
|
| 259 |
{ name = "pydantic-settings", specifier = ">=2.6.1" },
|
| 260 |
{ name = "typer", specifier = ">=0.9.0" },
|
| 261 |
]
|
|
|
|
| 231 |
|
| 232 |
[[package]]
|
| 233 |
name = "fastmcp"
|
| 234 |
+
version = "0.2.1.dev44+gbfae276.d20241130"
|
| 235 |
source = { editable = "." }
|
| 236 |
dependencies = [
|
| 237 |
{ name = "httpx" },
|
|
|
|
| 254 |
[package.metadata]
|
| 255 |
requires-dist = [
|
| 256 |
{ name = "httpx", specifier = ">=0.26.0" },
|
| 257 |
+
{ name = "mcp", specifier = ">=1.0.0,<2.0.0" },
|
| 258 |
+
{ name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
|
| 259 |
{ name = "pydantic-settings", specifier = ">=2.6.1" },
|
| 260 |
{ name = "typer", specifier = ">=0.9.0" },
|
| 261 |
]
|