Jeremiah Lowin commited on
Commit
cc20abf
·
1 Parent(s): 1d65837

Add support for prompts

Browse files
examples/echo.py CHANGED
@@ -10,10 +10,21 @@ 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()
 
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
- try:
137
- context = self.get_context()
138
- result = await self._tool_manager.call_tool(
139
- name, arguments, context=context
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 _convert_to_content(value: Any) -> Sequence[Union[TextContent, ImageContent]]:
386
- """Convert a tool result to MCP content types."""
387
-
388
- # Already a sequence of valid content types
389
- if isinstance(value, (list, tuple)):
390
- if all(isinstance(x, (TextContent, ImageContent)) for x in value):
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
- # Single content type
409
- if isinstance(value, (TextContent, ImageContent)):
410
- return [value]
 
411
 
412
- # Image helper
413
- if isinstance(value, Image):
414
- return [value.to_image_content()]
415
 
416
- # All other types - convert to JSON string with pydantic encoder
417
- return [
418
- TextContent(
419
- type="text",
420
- text=json.dumps(pydantic_core.to_jsonable_python(value)),
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.content[0].is_error is True
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
- content = result.content[0]
125
- assert content.type == "text"
126
- assert isinstance(content.text, str)
127
- assert "Test error" in content.text
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 '"text message"' in result.content[0].text
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.dev8+gfc560b3.d20241130"
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
  ]