fastmcp 2.8.1__py3-none-any.whl → 2.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/cli/cli.py +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +21 -5
- fastmcp/client/transports.py +17 -2
- fastmcp/contrib/mcp_mixin/README.md +79 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
- fastmcp/prompts/prompt.py +91 -11
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +11 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +27 -1
- fastmcp/server/auth/providers/bearer.py +32 -10
- fastmcp/server/context.py +41 -2
- fastmcp/server/http.py +8 -0
- fastmcp/server/middleware/__init__.py +6 -0
- fastmcp/server/middleware/error_handling.py +206 -0
- fastmcp/server/middleware/logging.py +165 -0
- fastmcp/server/middleware/middleware.py +236 -0
- fastmcp/server/middleware/rate_limiting.py +231 -0
- fastmcp/server/middleware/timing.py +156 -0
- fastmcp/server/proxy.py +250 -140
- fastmcp/server/server.py +320 -242
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +6 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/utilities/components.py +22 -2
- fastmcp/utilities/inspect.py +326 -0
- fastmcp/utilities/json_schema.py +67 -23
- fastmcp/utilities/mcp_config.py +13 -7
- fastmcp/utilities/openapi.py +5 -3
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/METADATA +2 -2
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/RECORD +38 -31
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/cli.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""FastMCP CLI tools."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import importlib.metadata
|
|
4
5
|
import importlib.util
|
|
5
6
|
import os
|
|
@@ -11,6 +12,7 @@ from typing import Annotated
|
|
|
11
12
|
|
|
12
13
|
import dotenv
|
|
13
14
|
import typer
|
|
15
|
+
from pydantic import TypeAdapter
|
|
14
16
|
from rich.console import Console
|
|
15
17
|
from rich.table import Table
|
|
16
18
|
from typer import Context, Exit
|
|
@@ -19,6 +21,7 @@ import fastmcp
|
|
|
19
21
|
from fastmcp.cli import claude
|
|
20
22
|
from fastmcp.cli import run as run_module
|
|
21
23
|
from fastmcp.server.server import FastMCP
|
|
24
|
+
from fastmcp.utilities.inspect import FastMCPInfo, inspect_fastmcp
|
|
22
25
|
from fastmcp.utilities.logging import get_logger
|
|
23
26
|
|
|
24
27
|
logger = get_logger("cli")
|
|
@@ -232,7 +235,7 @@ def run(
|
|
|
232
235
|
typer.Option(
|
|
233
236
|
"--transport",
|
|
234
237
|
"-t",
|
|
235
|
-
help="Transport protocol to use (stdio,
|
|
238
|
+
help="Transport protocol to use (stdio, http, or sse)",
|
|
236
239
|
),
|
|
237
240
|
] = None,
|
|
238
241
|
host: Annotated[
|
|
@@ -435,3 +438,98 @@ def install(
|
|
|
435
438
|
else:
|
|
436
439
|
logger.error(f"Failed to install {name} in Claude app")
|
|
437
440
|
sys.exit(1)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@app.command()
|
|
444
|
+
def inspect(
|
|
445
|
+
server_spec: str = typer.Argument(
|
|
446
|
+
...,
|
|
447
|
+
help="Python file to inspect, optionally with :object suffix",
|
|
448
|
+
),
|
|
449
|
+
output: Annotated[
|
|
450
|
+
Path,
|
|
451
|
+
typer.Option(
|
|
452
|
+
"--output",
|
|
453
|
+
"-o",
|
|
454
|
+
help="Output file path for the JSON report (default: server-info.json)",
|
|
455
|
+
),
|
|
456
|
+
] = Path("server-info.json"),
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Inspect a FastMCP server and generate a JSON report.
|
|
459
|
+
|
|
460
|
+
This command analyzes a FastMCP server (v1.x or v2.x) and generates
|
|
461
|
+
a comprehensive JSON report containing information about the server's
|
|
462
|
+
name, instructions, version, tools, prompts, resources, templates,
|
|
463
|
+
and capabilities.
|
|
464
|
+
|
|
465
|
+
Examples:
|
|
466
|
+
fastmcp inspect server.py
|
|
467
|
+
fastmcp inspect server.py -o report.json
|
|
468
|
+
fastmcp inspect server.py:mcp -o analysis.json
|
|
469
|
+
fastmcp inspect path/to/server.py:app -o /tmp/server-info.json
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
# Parse the server specification
|
|
473
|
+
file, server_object = run_module.parse_file_path(server_spec)
|
|
474
|
+
|
|
475
|
+
logger.debug(
|
|
476
|
+
"Inspecting server",
|
|
477
|
+
extra={
|
|
478
|
+
"file": str(file),
|
|
479
|
+
"server_object": server_object,
|
|
480
|
+
"output": str(output),
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
# Import the server
|
|
486
|
+
server = run_module.import_server(file, server_object)
|
|
487
|
+
|
|
488
|
+
# Get server information
|
|
489
|
+
async def get_info():
|
|
490
|
+
return await inspect_fastmcp(server)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
# Try to use existing event loop if available
|
|
494
|
+
asyncio.get_running_loop()
|
|
495
|
+
# If there's already a loop running, we need to run in a thread
|
|
496
|
+
import concurrent.futures
|
|
497
|
+
|
|
498
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
499
|
+
future = executor.submit(asyncio.run, get_info())
|
|
500
|
+
info = future.result()
|
|
501
|
+
except RuntimeError:
|
|
502
|
+
# No running loop, safe to use asyncio.run
|
|
503
|
+
info = asyncio.run(get_info())
|
|
504
|
+
|
|
505
|
+
info_json = TypeAdapter(FastMCPInfo).dump_json(info, indent=2)
|
|
506
|
+
|
|
507
|
+
# Ensure output directory exists
|
|
508
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
509
|
+
|
|
510
|
+
# Write JSON report (always pretty-printed)
|
|
511
|
+
with output.open("w", encoding="utf-8") as f:
|
|
512
|
+
f.write(info_json.decode("utf-8"))
|
|
513
|
+
|
|
514
|
+
logger.info(f"Server inspection complete. Report saved to {output}")
|
|
515
|
+
|
|
516
|
+
# Print summary to console
|
|
517
|
+
console.print(
|
|
518
|
+
f"[bold green]✓[/bold green] Inspected server: [bold]{info.name}[/bold]"
|
|
519
|
+
)
|
|
520
|
+
console.print(f" Tools: {len(info.tools)}")
|
|
521
|
+
console.print(f" Prompts: {len(info.prompts)}")
|
|
522
|
+
console.print(f" Resources: {len(info.resources)}")
|
|
523
|
+
console.print(f" Templates: {len(info.templates)}")
|
|
524
|
+
console.print(f" Report saved to: [cyan]{output}[/cyan]")
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
logger.error(
|
|
528
|
+
f"Failed to inspect server: {e}",
|
|
529
|
+
extra={
|
|
530
|
+
"server_spec": server_spec,
|
|
531
|
+
"error": str(e),
|
|
532
|
+
},
|
|
533
|
+
)
|
|
534
|
+
console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
|
|
535
|
+
sys.exit(1)
|
fastmcp/cli/run.py
CHANGED
|
@@ -4,14 +4,12 @@ import importlib.util
|
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
8
8
|
|
|
9
9
|
from fastmcp.utilities.logging import get_logger
|
|
10
10
|
|
|
11
11
|
logger = get_logger("cli.run")
|
|
12
12
|
|
|
13
|
-
TransportType = Literal["stdio", "streamable-http", "sse"]
|
|
14
|
-
|
|
15
13
|
|
|
16
14
|
def is_url(path: str) -> bool:
|
|
17
15
|
"""Check if a string is a URL."""
|
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -306,8 +306,7 @@ def OAuth(
|
|
|
306
306
|
httpx.AsyncClient (or appropriate FastMCP client/transport instance)
|
|
307
307
|
|
|
308
308
|
Args:
|
|
309
|
-
mcp_url: Full URL to the MCP endpoint (e.g
|
|
310
|
-
"http://host/mcp/sse")
|
|
309
|
+
mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/")
|
|
311
310
|
scopes: OAuth scopes to request. Can be a
|
|
312
311
|
space-separated string or a list of strings.
|
|
313
312
|
client_name: Name for this client during registration
|
fastmcp/client/client.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, Generic, Literal, cast, overload
|
|
|
7
7
|
import anyio
|
|
8
8
|
import httpx
|
|
9
9
|
import mcp.types
|
|
10
|
+
import pydantic_core
|
|
10
11
|
from exceptiongroup import catch
|
|
11
12
|
from mcp import ClientSession
|
|
12
13
|
from pydantic import AnyUrl
|
|
@@ -508,13 +509,13 @@ class Client(Generic[ClientTransportT]):
|
|
|
508
509
|
|
|
509
510
|
# --- Prompt ---
|
|
510
511
|
async def get_prompt_mcp(
|
|
511
|
-
self, name: str, arguments: dict[str,
|
|
512
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
512
513
|
) -> mcp.types.GetPromptResult:
|
|
513
514
|
"""Send a prompts/get request and return the complete MCP protocol result.
|
|
514
515
|
|
|
515
516
|
Args:
|
|
516
517
|
name (str): The name of the prompt to retrieve.
|
|
517
|
-
arguments (dict[str,
|
|
518
|
+
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
|
518
519
|
|
|
519
520
|
Returns:
|
|
520
521
|
mcp.types.GetPromptResult: The complete response object from the protocol,
|
|
@@ -523,17 +524,32 @@ class Client(Generic[ClientTransportT]):
|
|
|
523
524
|
Raises:
|
|
524
525
|
RuntimeError: If called while the client is not connected.
|
|
525
526
|
"""
|
|
526
|
-
|
|
527
|
+
# Serialize arguments for MCP protocol - convert non-string values to JSON
|
|
528
|
+
serialized_arguments: dict[str, str] | None = None
|
|
529
|
+
if arguments:
|
|
530
|
+
serialized_arguments = {}
|
|
531
|
+
for key, value in arguments.items():
|
|
532
|
+
if isinstance(value, str):
|
|
533
|
+
serialized_arguments[key] = value
|
|
534
|
+
else:
|
|
535
|
+
# Use pydantic_core.to_json for consistent serialization
|
|
536
|
+
serialized_arguments[key] = pydantic_core.to_json(value).decode(
|
|
537
|
+
"utf-8"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
result = await self.session.get_prompt(
|
|
541
|
+
name=name, arguments=serialized_arguments
|
|
542
|
+
)
|
|
527
543
|
return result
|
|
528
544
|
|
|
529
545
|
async def get_prompt(
|
|
530
|
-
self, name: str, arguments: dict[str,
|
|
546
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
531
547
|
) -> mcp.types.GetPromptResult:
|
|
532
548
|
"""Retrieve a rendered prompt message list from the server.
|
|
533
549
|
|
|
534
550
|
Args:
|
|
535
551
|
name (str): The name of the prompt to retrieve.
|
|
536
|
-
arguments (dict[str,
|
|
552
|
+
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
|
537
553
|
|
|
538
554
|
Returns:
|
|
539
555
|
mcp.types.GetPromptResult: The complete response object from the protocol,
|
fastmcp/client/transports.py
CHANGED
|
@@ -9,6 +9,7 @@ import warnings
|
|
|
9
9
|
from collections.abc import AsyncIterator, Callable
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Literal, TypedDict, TypeVar, cast, overload
|
|
12
|
+
from urllib.parse import urlparse, urlunparse
|
|
12
13
|
|
|
13
14
|
import anyio
|
|
14
15
|
import httpx
|
|
@@ -159,6 +160,13 @@ class SSETransport(ClientTransport):
|
|
|
159
160
|
url = str(url)
|
|
160
161
|
if not isinstance(url, str) or not url.startswith("http"):
|
|
161
162
|
raise ValueError("Invalid HTTP/S URL provided for SSE.")
|
|
163
|
+
|
|
164
|
+
# Ensure the URL path ends with a trailing slash to avoid automatic redirects
|
|
165
|
+
parsed = urlparse(url)
|
|
166
|
+
if not parsed.path.endswith("/"):
|
|
167
|
+
parsed = parsed._replace(path=parsed.path + "/")
|
|
168
|
+
url = urlunparse(parsed)
|
|
169
|
+
|
|
162
170
|
self.url = url
|
|
163
171
|
self.headers = headers or {}
|
|
164
172
|
self._set_auth(auth)
|
|
@@ -227,6 +235,13 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
227
235
|
url = str(url)
|
|
228
236
|
if not isinstance(url, str) or not url.startswith("http"):
|
|
229
237
|
raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
|
|
238
|
+
|
|
239
|
+
# Ensure the URL path ends with a trailing slash to avoid automatic redirects
|
|
240
|
+
parsed = urlparse(url)
|
|
241
|
+
if not parsed.path.endswith("/"):
|
|
242
|
+
parsed = parsed._replace(path=parsed.path + "/")
|
|
243
|
+
url = urlunparse(parsed)
|
|
244
|
+
|
|
230
245
|
self.url = url
|
|
231
246
|
self.headers = headers or {}
|
|
232
247
|
self._set_auth(auth)
|
|
@@ -721,11 +736,11 @@ class MCPConfigTransport(ClientTransport):
|
|
|
721
736
|
"mcpServers": {
|
|
722
737
|
"weather": {
|
|
723
738
|
"url": "https://weather-api.example.com/mcp",
|
|
724
|
-
"transport": "
|
|
739
|
+
"transport": "http"
|
|
725
740
|
},
|
|
726
741
|
"calendar": {
|
|
727
742
|
"url": "https://calendar-api.example.com/mcp",
|
|
728
|
-
"transport": "
|
|
743
|
+
"transport": "http"
|
|
729
744
|
}
|
|
730
745
|
}
|
|
731
746
|
}
|
|
@@ -1,26 +1,103 @@
|
|
|
1
|
+
from mcp.types import ToolAnnotations
|
|
2
|
+
|
|
1
3
|
# MCP Mixin
|
|
2
4
|
|
|
3
5
|
This module provides the `MCPMixin` base class and associated decorators (`@mcp_tool`, `@mcp_resource`, `@mcp_prompt`).
|
|
4
6
|
|
|
5
7
|
It allows developers to easily define classes whose methods can be registered as tools, resources, or prompts with a `FastMCP` server instance using the `register_all()`, `register_tools()`, `register_resources()`, or `register_prompts()` methods provided by the mixin.
|
|
6
8
|
|
|
9
|
+
Includes support for
|
|
10
|
+
Tools:
|
|
11
|
+
* [enable/disable](https://gofastmcp.com/servers/tools#disabling-tools)
|
|
12
|
+
* [annotations](https://gofastmcp.com/servers/tools#annotations-2)
|
|
13
|
+
* [excluded arguments](https://gofastmcp.com/servers/tools#excluding-arguments)
|
|
14
|
+
|
|
15
|
+
Prompts:
|
|
16
|
+
* [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts)
|
|
17
|
+
|
|
18
|
+
Resources:
|
|
19
|
+
* [enable/disabe](https://gofastmcp.com/servers/resources#disabling-resources)
|
|
20
|
+
|
|
7
21
|
## Usage
|
|
8
22
|
|
|
9
23
|
Inherit from `MCPMixin` and use the decorators on the methods you want to register.
|
|
10
24
|
|
|
11
25
|
```python
|
|
26
|
+
from mcp.types import ToolAnnotations
|
|
12
27
|
from fastmcp import FastMCP
|
|
13
|
-
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource
|
|
28
|
+
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt
|
|
14
29
|
|
|
15
30
|
class MyComponent(MCPMixin):
|
|
16
31
|
@mcp_tool(name="my_tool", description="Does something cool.")
|
|
17
32
|
def tool_method(self):
|
|
18
33
|
return "Tool executed!"
|
|
19
34
|
|
|
35
|
+
# example of disabled tool
|
|
36
|
+
@mcp_tool(name="my_tool", description="Does something cool.", enabled=False)
|
|
37
|
+
def disabled_tool_method(self):
|
|
38
|
+
# This function can't be called by client because it's disabled
|
|
39
|
+
return "You'll never get here!"
|
|
40
|
+
|
|
41
|
+
# example of excluded parameter tool
|
|
42
|
+
@mcp_tool(
|
|
43
|
+
name="my_tool", description="Does something cool.",
|
|
44
|
+
enabled=False, exclude_args=['delete_everything'],
|
|
45
|
+
)
|
|
46
|
+
def excluded_param_tool_method(self, delete_everything=False):
|
|
47
|
+
# MCP tool calls can't pass the "delete_everything" argument
|
|
48
|
+
if delete_everything:
|
|
49
|
+
return "Nothing to delete, I bet you're not a tool :)"
|
|
50
|
+
return "You might be a tool if..."
|
|
51
|
+
|
|
52
|
+
# example tool w/annotations
|
|
53
|
+
@mcp_tool(
|
|
54
|
+
name="my_tool", description="Does something cool.",
|
|
55
|
+
annotations=ToolAnnotations(
|
|
56
|
+
title="Attn LLM, use this tool first!",
|
|
57
|
+
readOnlyHint=False,
|
|
58
|
+
destructiveHint=False,
|
|
59
|
+
idempotentHint=False,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
def tool_method(self):
|
|
63
|
+
return "Tool executed!"
|
|
64
|
+
|
|
65
|
+
# example tool w/everything
|
|
66
|
+
@mcp_tool(
|
|
67
|
+
name="my_tool", description="Does something cool.",
|
|
68
|
+
enabled=True,
|
|
69
|
+
exclude_args=['delete_all'],
|
|
70
|
+
annotations=ToolAnnotations(
|
|
71
|
+
title="Attn LLM, use this tool first!",
|
|
72
|
+
readOnlyHint=False,
|
|
73
|
+
destructiveHint=False,
|
|
74
|
+
idempotentHint=False,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
def tool_method(self, delete_all=False):
|
|
78
|
+
if delete_all:
|
|
79
|
+
return "99 records deleted. I bet you're not a tool :)"
|
|
80
|
+
return "Tool executed, but you might be a tool!"
|
|
81
|
+
|
|
20
82
|
@mcp_resource(uri="component://data")
|
|
21
83
|
def resource_method(self):
|
|
22
84
|
return {"data": "some data"}
|
|
23
85
|
|
|
86
|
+
# Disabled resource
|
|
87
|
+
@mcp_resource(uri="component://data", enabled=False)
|
|
88
|
+
def resource_method(self):
|
|
89
|
+
return {"data": "some data"}
|
|
90
|
+
|
|
91
|
+
# prompt
|
|
92
|
+
@mcp_prompt(name="A prompt")
|
|
93
|
+
def prompt_method(self, name):
|
|
94
|
+
return f"Whats up {name}?"
|
|
95
|
+
|
|
96
|
+
# disabled prompt
|
|
97
|
+
@mcp_prompt(name="A prompt", enabled=False)
|
|
98
|
+
def prompt_method(self, name):
|
|
99
|
+
return f"Whats up {name}?"
|
|
100
|
+
|
|
24
101
|
mcp_server = FastMCP()
|
|
25
102
|
component = MyComponent()
|
|
26
103
|
|
|
@@ -36,4 +113,4 @@ component.register_all(mcp_server, prefix="my_comp")
|
|
|
36
113
|
# Or 'my_tool' and 'component://data' are registered (if no prefix used)
|
|
37
114
|
```
|
|
38
115
|
|
|
39
|
-
The `prefix` argument in registration methods is optional. If omitted, methods are registered with their original decorated names/URIs. Individual separators (`tools_separator`, `resources_separator`, `prompts_separator`) can also be provided to `register_all` to change the separator for specific types.
|
|
116
|
+
The `prefix` argument in registration methods is optional. If omitted, methods are registered with their original decorated names/URIs. Individual separators (`tools_separator`, `resources_separator`, `prompts_separator`) can also be provided to `register_all` to change the separator for specific types.
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from collections.abc import Callable
|
|
4
4
|
from typing import TYPE_CHECKING, Any
|
|
5
5
|
|
|
6
|
+
from mcp.types import ToolAnnotations
|
|
7
|
+
|
|
6
8
|
from fastmcp.prompts.prompt import Prompt
|
|
7
9
|
from fastmcp.resources.resource import Resource
|
|
8
10
|
from fastmcp.tools.tool import Tool
|
|
@@ -23,6 +25,10 @@ def mcp_tool(
|
|
|
23
25
|
name: str | None = None,
|
|
24
26
|
description: str | None = None,
|
|
25
27
|
tags: set[str] | None = None,
|
|
28
|
+
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
|
29
|
+
exclude_args: list[str] | None = None,
|
|
30
|
+
serializer: Callable[[Any], str] | None = None,
|
|
31
|
+
enabled: bool | None = None,
|
|
26
32
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
27
33
|
"""Decorator to mark a method as an MCP tool for later registration."""
|
|
28
34
|
|
|
@@ -31,6 +37,10 @@ def mcp_tool(
|
|
|
31
37
|
"name": name or func.__name__,
|
|
32
38
|
"description": description,
|
|
33
39
|
"tags": tags,
|
|
40
|
+
"annotations": annotations,
|
|
41
|
+
"exclude_args": exclude_args,
|
|
42
|
+
"serializer": serializer,
|
|
43
|
+
"enabled": enabled,
|
|
34
44
|
}
|
|
35
45
|
call_args = {k: v for k, v in call_args.items() if v is not None}
|
|
36
46
|
setattr(func, _MCP_REGISTRATION_TOOL_ATTR, call_args)
|
|
@@ -46,6 +56,7 @@ def mcp_resource(
|
|
|
46
56
|
description: str | None = None,
|
|
47
57
|
mime_type: str | None = None,
|
|
48
58
|
tags: set[str] | None = None,
|
|
59
|
+
enabled: bool | None = None,
|
|
49
60
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
50
61
|
"""Decorator to mark a method as an MCP resource for later registration."""
|
|
51
62
|
|
|
@@ -56,6 +67,7 @@ def mcp_resource(
|
|
|
56
67
|
"description": description,
|
|
57
68
|
"mime_type": mime_type,
|
|
58
69
|
"tags": tags,
|
|
70
|
+
"enabled": enabled,
|
|
59
71
|
}
|
|
60
72
|
call_args = {k: v for k, v in call_args.items() if v is not None}
|
|
61
73
|
|
|
@@ -70,6 +82,7 @@ def mcp_prompt(
|
|
|
70
82
|
name: str | None = None,
|
|
71
83
|
description: str | None = None,
|
|
72
84
|
tags: set[str] | None = None,
|
|
85
|
+
enabled: bool | None = None,
|
|
73
86
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
74
87
|
"""Decorator to mark a method as an MCP prompt for later registration."""
|
|
75
88
|
|
|
@@ -78,6 +91,7 @@ def mcp_prompt(
|
|
|
78
91
|
"name": name or func.__name__,
|
|
79
92
|
"description": description,
|
|
80
93
|
"tags": tags,
|
|
94
|
+
"enabled": enabled,
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
call_args = {k: v for k, v in call_args.items() if v is not None}
|
fastmcp/prompts/prompt.py
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations as _annotations
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
|
+
import json
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
8
|
from collections.abc import Awaitable, Callable, Sequence
|
|
8
|
-
from typing import
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
11
|
import pydantic_core
|
|
11
12
|
from mcp.types import Prompt as MCPPrompt
|
|
12
13
|
from mcp.types import PromptArgument as MCPPromptArgument
|
|
13
14
|
from mcp.types import PromptMessage, Role, TextContent
|
|
14
|
-
from pydantic import Field, TypeAdapter
|
|
15
|
+
from pydantic import Field, TypeAdapter
|
|
15
16
|
|
|
16
17
|
from fastmcp.exceptions import PromptError
|
|
17
18
|
from fastmcp.server.dependencies import get_context
|
|
@@ -25,10 +26,6 @@ from fastmcp.utilities.types import (
|
|
|
25
26
|
get_cached_typeadapter,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
if TYPE_CHECKING:
|
|
29
|
-
pass
|
|
30
|
-
|
|
31
|
-
|
|
32
29
|
logger = get_logger(__name__)
|
|
33
30
|
|
|
34
31
|
|
|
@@ -155,7 +152,7 @@ class FunctionPrompt(Prompt):
|
|
|
155
152
|
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
156
153
|
raise ValueError("Functions with **kwargs are not supported as prompts")
|
|
157
154
|
|
|
158
|
-
description = description or fn
|
|
155
|
+
description = description or inspect.getdoc(fn)
|
|
159
156
|
|
|
160
157
|
# if the fn is a callable class, we need to get the __call__ method from here out
|
|
161
158
|
if not inspect.isroutine(fn):
|
|
@@ -181,17 +178,43 @@ class FunctionPrompt(Prompt):
|
|
|
181
178
|
arguments: list[PromptArgument] = []
|
|
182
179
|
if "properties" in parameters:
|
|
183
180
|
for param_name, param in parameters["properties"].items():
|
|
181
|
+
arg_description = param.get("description")
|
|
182
|
+
|
|
183
|
+
# For non-string parameters, append JSON schema info to help users
|
|
184
|
+
# understand the expected format when passing as strings (MCP requirement)
|
|
185
|
+
if param_name in sig.parameters:
|
|
186
|
+
sig_param = sig.parameters[param_name]
|
|
187
|
+
if (
|
|
188
|
+
sig_param.annotation != inspect.Parameter.empty
|
|
189
|
+
and sig_param.annotation is not str
|
|
190
|
+
and param_name != context_kwarg
|
|
191
|
+
):
|
|
192
|
+
# Get the JSON schema for this specific parameter type
|
|
193
|
+
try:
|
|
194
|
+
param_adapter = get_cached_typeadapter(sig_param.annotation)
|
|
195
|
+
param_schema = param_adapter.json_schema()
|
|
196
|
+
|
|
197
|
+
# Create compact schema representation
|
|
198
|
+
schema_str = json.dumps(param_schema, separators=(",", ":"))
|
|
199
|
+
|
|
200
|
+
# Append schema info to description
|
|
201
|
+
schema_note = f"Provide as a JSON string matching the following schema: {schema_str}"
|
|
202
|
+
if arg_description:
|
|
203
|
+
arg_description = f"{arg_description}\n\n{schema_note}"
|
|
204
|
+
else:
|
|
205
|
+
arg_description = schema_note
|
|
206
|
+
except Exception:
|
|
207
|
+
# If schema generation fails, skip enhancement
|
|
208
|
+
pass
|
|
209
|
+
|
|
184
210
|
arguments.append(
|
|
185
211
|
PromptArgument(
|
|
186
212
|
name=param_name,
|
|
187
|
-
description=
|
|
213
|
+
description=arg_description,
|
|
188
214
|
required=param_name in parameters.get("required", []),
|
|
189
215
|
)
|
|
190
216
|
)
|
|
191
217
|
|
|
192
|
-
# ensure the arguments are properly cast
|
|
193
|
-
fn = validate_call(fn)
|
|
194
|
-
|
|
195
218
|
return cls(
|
|
196
219
|
name=func_name,
|
|
197
220
|
description=description,
|
|
@@ -201,6 +224,60 @@ class FunctionPrompt(Prompt):
|
|
|
201
224
|
fn=fn,
|
|
202
225
|
)
|
|
203
226
|
|
|
227
|
+
def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
228
|
+
"""Convert string arguments to expected types based on function signature."""
|
|
229
|
+
from fastmcp.server.context import Context
|
|
230
|
+
|
|
231
|
+
sig = inspect.signature(self.fn)
|
|
232
|
+
converted_kwargs = {}
|
|
233
|
+
|
|
234
|
+
# Find context parameter name if any
|
|
235
|
+
context_param_name = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
236
|
+
|
|
237
|
+
for param_name, param_value in kwargs.items():
|
|
238
|
+
if param_name in sig.parameters:
|
|
239
|
+
param = sig.parameters[param_name]
|
|
240
|
+
|
|
241
|
+
# Skip Context parameters - they're handled separately
|
|
242
|
+
if param_name == context_param_name:
|
|
243
|
+
converted_kwargs[param_name] = param_value
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# If parameter has no annotation or annotation is str, pass as-is
|
|
247
|
+
if (
|
|
248
|
+
param.annotation == inspect.Parameter.empty
|
|
249
|
+
or param.annotation is str
|
|
250
|
+
):
|
|
251
|
+
converted_kwargs[param_name] = param_value
|
|
252
|
+
# If argument is not a string, pass as-is (already properly typed)
|
|
253
|
+
elif not isinstance(param_value, str):
|
|
254
|
+
converted_kwargs[param_name] = param_value
|
|
255
|
+
else:
|
|
256
|
+
# Try to convert string argument using type adapter
|
|
257
|
+
try:
|
|
258
|
+
adapter = get_cached_typeadapter(param.annotation)
|
|
259
|
+
# Try JSON parsing first for complex types
|
|
260
|
+
try:
|
|
261
|
+
converted_kwargs[param_name] = adapter.validate_json(
|
|
262
|
+
param_value
|
|
263
|
+
)
|
|
264
|
+
except (ValueError, TypeError, pydantic_core.ValidationError):
|
|
265
|
+
# Fallback to direct validation
|
|
266
|
+
converted_kwargs[param_name] = adapter.validate_python(
|
|
267
|
+
param_value
|
|
268
|
+
)
|
|
269
|
+
except (ValueError, TypeError, pydantic_core.ValidationError) as e:
|
|
270
|
+
# If conversion fails, provide informative error
|
|
271
|
+
raise PromptError(
|
|
272
|
+
f"Could not convert argument '{param_name}' with value '{param_value}' "
|
|
273
|
+
f"to expected type {param.annotation}. Error: {e}"
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
# Parameter not in function signature, pass as-is
|
|
277
|
+
converted_kwargs[param_name] = param_value
|
|
278
|
+
|
|
279
|
+
return converted_kwargs
|
|
280
|
+
|
|
204
281
|
async def render(
|
|
205
282
|
self,
|
|
206
283
|
arguments: dict[str, Any] | None = None,
|
|
@@ -223,6 +300,9 @@ class FunctionPrompt(Prompt):
|
|
|
223
300
|
if context_kwarg and context_kwarg not in kwargs:
|
|
224
301
|
kwargs[context_kwarg] = get_context()
|
|
225
302
|
|
|
303
|
+
# Convert string arguments to expected types when needed
|
|
304
|
+
kwargs = self._convert_string_arguments(kwargs)
|
|
305
|
+
|
|
226
306
|
# Call function and check if result is a coroutine
|
|
227
307
|
result = self.fn(**kwargs)
|
|
228
308
|
if inspect.iscoroutine(result):
|