Spaces:
Running
Running
Merge pull request #781 from jlowin/enabled
Browse filesSupport enable/disable for all FastMCP components (tools, prompts, resources, templates)
- docs/patterns/tool-transformation.mdx +24 -0
- docs/servers/prompts.mdx +27 -0
- docs/servers/resources.mdx +27 -0
- docs/servers/tools.mdx +47 -16
- src/fastmcp/exceptions.py +4 -0
- src/fastmcp/prompts/prompt.py +4 -1
- src/fastmcp/prompts/prompt_manager.py +4 -2
- src/fastmcp/resources/resource.py +4 -0
- src/fastmcp/resources/template.py +5 -0
- src/fastmcp/server/proxy.py +6 -6
- src/fastmcp/server/server.py +157 -44
- src/fastmcp/tools/tool.py +6 -0
- src/fastmcp/tools/tool_transform.py +2 -0
- src/fastmcp/utilities/components.py +14 -1
- tests/server/test_proxy.py +3 -1
- tests/server/test_server_interactions.py +381 -0
- tests/tools/test_tool_transform.py +69 -24
docs/patterns/tool-transformation.mdx
CHANGED
|
@@ -53,6 +53,30 @@ product_search_tool = Tool.from_tool(
|
|
| 53 |
|
| 54 |
mcp.add_tool(product_search_tool)
|
| 55 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
Now, clients see a tool named `find_products` with a clear, domain-specific purpose and relevant tags, even though it still uses the original generic `search` function's logic.
|
| 57 |
|
| 58 |
### Parameters
|
|
|
|
| 53 |
|
| 54 |
mcp.add_tool(product_search_tool)
|
| 55 |
```
|
| 56 |
+
|
| 57 |
+
<Tip>
|
| 58 |
+
When you transform a tool, the original tool remains registered on the server. To avoid confusing an LLM with two similar tools, you can disable the original one:
|
| 59 |
+
|
| 60 |
+
```python
|
| 61 |
+
from fastmcp import FastMCP
|
| 62 |
+
from fastmcp.tools import Tool
|
| 63 |
+
|
| 64 |
+
mcp = FastMCP()
|
| 65 |
+
|
| 66 |
+
# The original, generic tool
|
| 67 |
+
@mcp.tool
|
| 68 |
+
def search(query: str, category: str = "all") -> list[dict]:
|
| 69 |
+
...
|
| 70 |
+
|
| 71 |
+
# Create a more domain-specific version
|
| 72 |
+
product_search_tool = Tool.from_tool(search, ...)
|
| 73 |
+
mcp.add_tool(product_search_tool)
|
| 74 |
+
|
| 75 |
+
# Disable the original tool
|
| 76 |
+
search.disable()
|
| 77 |
+
```
|
| 78 |
+
</Tip>
|
| 79 |
+
|
| 80 |
Now, clients see a tool named `find_products` with a clear, domain-specific purpose and relevant tags, even though it still uses the original generic `search` function's logic.
|
| 81 |
|
| 82 |
### Parameters
|
docs/servers/prompts.mdx
CHANGED
|
@@ -147,7 +147,32 @@ def data_analysis_prompt(
|
|
| 147 |
- **`name`**: Sets the explicit prompt name exposed via MCP.
|
| 148 |
- **`description`**: Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose.
|
| 149 |
- **`tags`**: A set of strings used to categorize the prompt. Clients *might* use tags to filter or group available prompts.
|
|
|
|
|
|
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
### Asynchronous Prompts
|
| 152 |
|
| 153 |
FastMCP seamlessly supports both standard (`def`) and asynchronous (`async def`) functions as prompts.
|
|
@@ -191,6 +216,8 @@ async def generate_report_request(report_type: str, ctx: Context) -> str:
|
|
| 191 |
|
| 192 |
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
| 193 |
|
|
|
|
|
|
|
| 194 |
## Server Behavior
|
| 195 |
|
| 196 |
### Duplicate Prompts
|
|
|
|
| 147 |
- **`name`**: Sets the explicit prompt name exposed via MCP.
|
| 148 |
- **`description`**: Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose.
|
| 149 |
- **`tags`**: A set of strings used to categorize the prompt. Clients *might* use tags to filter or group available prompts.
|
| 150 |
+
- **`enabled`**: A boolean to enable or disable the prompt (defaults to `True`). See [Disabling Prompts](#disabling-prompts) for more information.
|
| 151 |
+
### Disabling Prompts
|
| 152 |
|
| 153 |
+
<VersionBadge version="2.8.0" />
|
| 154 |
+
|
| 155 |
+
You can control the visibility and availability of prompts by enabling or disabling them. Disabled prompts will not appear in the list of available prompts, and attempting to call a disabled prompt will result in an "Unknown prompt" error.
|
| 156 |
+
|
| 157 |
+
By default, all prompts are enabled. You can disable a prompt upon creation using the `enabled` parameter in the decorator:
|
| 158 |
+
|
| 159 |
+
```python
|
| 160 |
+
@mcp.prompt(enabled=False)
|
| 161 |
+
def experimental_prompt():
|
| 162 |
+
"""This prompt is not ready for use."""
|
| 163 |
+
return "This is an experimental prompt."
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
You can also toggle a prompt's state programmatically after it has been created:
|
| 167 |
+
|
| 168 |
+
```python
|
| 169 |
+
@mcp.prompt
|
| 170 |
+
def seasonal_prompt(): return "Happy Holidays!"
|
| 171 |
+
|
| 172 |
+
# Disable and re-enable the prompt
|
| 173 |
+
seasonal_prompt.disable()
|
| 174 |
+
seasonal_prompt.enable()
|
| 175 |
+
```
|
| 176 |
### Asynchronous Prompts
|
| 177 |
|
| 178 |
FastMCP seamlessly supports both standard (`def`) and asynchronous (`async def`) functions as prompts.
|
|
|
|
| 216 |
|
| 217 |
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).
|
| 218 |
|
| 219 |
+
|
| 220 |
+
|
| 221 |
## Server Behavior
|
| 222 |
|
| 223 |
### Duplicate Prompts
|
docs/servers/resources.mdx
CHANGED
|
@@ -94,6 +94,33 @@ def get_application_status() -> dict:
|
|
| 94 |
- **`description`**: Explanation of the resource (defaults to docstring).
|
| 95 |
- **`mime_type`**: Specifies the content type (FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types).
|
| 96 |
- **`tags`**: A set of strings for categorization, potentially used by clients for filtering.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
### Accessing MCP Context
|
| 99 |
|
|
|
|
| 94 |
- **`description`**: Explanation of the resource (defaults to docstring).
|
| 95 |
- **`mime_type`**: Specifies the content type (FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types).
|
| 96 |
- **`tags`**: A set of strings for categorization, potentially used by clients for filtering.
|
| 97 |
+
- **`enabled`**: A boolean to enable or disable the resource (defaults to `True`). See [Disabling Resources](#disabling-resources) for more information.
|
| 98 |
+
|
| 99 |
+
### Disabling Resources
|
| 100 |
+
|
| 101 |
+
<VersionBadge version="2.8.0" />
|
| 102 |
+
|
| 103 |
+
You can control the visibility and availability of resources and templates by enabling or disabling them. Disabled resources will not appear in the list of available resources or templates, and attempting to read a disabled resource will result in an "Unknown resource" error.
|
| 104 |
+
|
| 105 |
+
By default, all resources are enabled. You can disable a resource upon creation using the `enabled` parameter in the decorator:
|
| 106 |
+
|
| 107 |
+
```python
|
| 108 |
+
@mcp.resource("data://secret", enabled=False)
|
| 109 |
+
def get_secret_data():
|
| 110 |
+
"""This resource is currently disabled."""
|
| 111 |
+
return "Secret data"
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
You can also toggle a resource's state programmatically after it has been created:
|
| 115 |
+
|
| 116 |
+
```python
|
| 117 |
+
@mcp.resource("data://config")
|
| 118 |
+
def get_config(): return {"version": 1}
|
| 119 |
+
|
| 120 |
+
# Disable and re-enable the resource
|
| 121 |
+
get_config.disable()
|
| 122 |
+
get_config.enable()
|
| 123 |
+
```
|
| 124 |
|
| 125 |
### Accessing MCP Context
|
| 126 |
|
docs/servers/tools.mdx
CHANGED
|
@@ -169,27 +169,58 @@ def search_products_implementation(query: str, category: str | None = None) -> l
|
|
| 169 |
|
| 170 |
- **`name`**: Sets the explicit tool name exposed via MCP.
|
| 171 |
- **`description`**: Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose.
|
| 172 |
-
- **`tags`**: A set of strings
|
|
|
|
|
|
|
| 173 |
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
<VersionBadge version="2.6.0" />
|
| 177 |
-
A list of argument names to exclude from the tool schema shown to the LLM. This is useful for arguments that are injected at runtime (such as `state`, `user_id`, or credentials) and should not be exposed to the LLM or client. Only arguments with default values can be excluded; attempting to exclude a required argument will raise an error.
|
| 178 |
-
|
| 179 |
|
| 180 |
-
|
| 181 |
|
| 182 |
-
|
| 183 |
-
@mcp.tool(
|
| 184 |
-
name="get_user_details",
|
| 185 |
-
exclude_args=["user_id"]
|
| 186 |
-
)
|
| 187 |
-
def get_user_details(user_id: str = None) -> str:
|
| 188 |
-
# user_id will be injected by the server, not provided by the LLM
|
| 189 |
-
...
|
| 190 |
-
```
|
| 191 |
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
### Async Tools
|
| 195 |
|
|
|
|
| 169 |
|
| 170 |
- **`name`**: Sets the explicit tool name exposed via MCP.
|
| 171 |
- **`description`**: Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose.
|
| 172 |
+
- **`tags`**: A set of strings to categorize the tool. Clients *might* use tags to filter or group available tools.
|
| 173 |
+
- **`enabled`**: A boolean to enable or disable the tool (defaults to `True`). See [Disabling Tools](#disabling-tools) for more information.
|
| 174 |
+
- **`exclude_args`**: A list of argument names to exclude from the tool schema shown to the LLM. See [Excluding Arguments](#excluding-arguments) for more information.
|
| 175 |
|
| 176 |
+
### Excluding Arguments
|
| 177 |
|
| 178 |
+
<VersionBadge version="2.6.0" />
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
You can exclude certain arguments from the tool schema shown to the LLM. This is useful for arguments that are injected at runtime (such as `state`, `user_id`, or credentials) and should not be exposed to the LLM or client. Only arguments with default values can be excluded; attempting to exclude a required argument will raise an error.
|
| 181 |
|
| 182 |
+
Example:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
```python
|
| 185 |
+
@mcp.tool(
|
| 186 |
+
name="get_user_details",
|
| 187 |
+
exclude_args=["user_id"]
|
| 188 |
+
)
|
| 189 |
+
def get_user_details(user_id: str = None) -> str:
|
| 190 |
+
# user_id will be injected by the server, not provided by the LLM
|
| 191 |
+
...
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
With this configuration, `user_id` will not appear in the tool's parameter schema, but can still be set by the server or framework at runtime.
|
| 195 |
+
|
| 196 |
+
For more complex tool transformations, see [Transforming Tools](/patterns/tool-transformation).
|
| 197 |
+
|
| 198 |
+
### Disabling Tools
|
| 199 |
+
|
| 200 |
+
<VersionBadge version="2.8.0" />
|
| 201 |
+
|
| 202 |
+
You can control the visibility and availability of tools by enabling or disabling them. This is useful for feature flagging, maintenance, or dynamically changing the toolset available to a client. Disabled tools will not appear in the list of available tools returned by `list_tools`, and attempting to call a disabled tool will result in an "Unknown tool" error, just as if the tool did not exist.
|
| 203 |
+
|
| 204 |
+
By default, all tools are enabled. You can disable a tool upon creation using the `enabled` parameter in the decorator:
|
| 205 |
+
|
| 206 |
+
```python
|
| 207 |
+
@mcp.tool(enabled=False)
|
| 208 |
+
def maintenance_tool():
|
| 209 |
+
"""This tool is currently under maintenance."""
|
| 210 |
+
return "This tool is disabled."
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
You can also toggle a tool's state programmatically after it has been created:
|
| 214 |
+
|
| 215 |
+
```python
|
| 216 |
+
@mcp.tool
|
| 217 |
+
def dynamic_tool():
|
| 218 |
+
return "I am a dynamic tool."
|
| 219 |
+
|
| 220 |
+
# Disable and re-enable the tool
|
| 221 |
+
dynamic_tool.disable()
|
| 222 |
+
dynamic_tool.enable()
|
| 223 |
+
```
|
| 224 |
|
| 225 |
### Async Tools
|
| 226 |
|
src/fastmcp/exceptions.py
CHANGED
|
@@ -33,3 +33,7 @@ class ClientError(Exception):
|
|
| 33 |
|
| 34 |
class NotFoundError(Exception):
|
| 35 |
"""Object not found."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
class NotFoundError(Exception):
|
| 35 |
"""Object not found."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class DisabledError(Exception):
|
| 39 |
+
"""Object is disabled."""
|
src/fastmcp/prompts/prompt.py
CHANGED
|
@@ -96,6 +96,7 @@ class Prompt(FastMCPComponent, ABC):
|
|
| 96 |
name: str | None = None,
|
| 97 |
description: str | None = None,
|
| 98 |
tags: set[str] | None = None,
|
|
|
|
| 99 |
) -> FunctionPrompt:
|
| 100 |
"""Create a Prompt from a function.
|
| 101 |
|
|
@@ -106,7 +107,7 @@ class Prompt(FastMCPComponent, ABC):
|
|
| 106 |
- A sequence of any of the above
|
| 107 |
"""
|
| 108 |
return FunctionPrompt.from_function(
|
| 109 |
-
fn=fn, name=name, description=description, tags=tags
|
| 110 |
)
|
| 111 |
|
| 112 |
@abstractmethod
|
|
@@ -130,6 +131,7 @@ class FunctionPrompt(Prompt):
|
|
| 130 |
name: str | None = None,
|
| 131 |
description: str | None = None,
|
| 132 |
tags: set[str] | None = None,
|
|
|
|
| 133 |
) -> FunctionPrompt:
|
| 134 |
"""Create a Prompt from a function.
|
| 135 |
|
|
@@ -195,6 +197,7 @@ class FunctionPrompt(Prompt):
|
|
| 195 |
description=description,
|
| 196 |
arguments=arguments,
|
| 197 |
tags=tags or set(),
|
|
|
|
| 198 |
fn=fn,
|
| 199 |
)
|
| 200 |
|
|
|
|
| 96 |
name: str | None = None,
|
| 97 |
description: str | None = None,
|
| 98 |
tags: set[str] | None = None,
|
| 99 |
+
enabled: bool | None = None,
|
| 100 |
) -> FunctionPrompt:
|
| 101 |
"""Create a Prompt from a function.
|
| 102 |
|
|
|
|
| 107 |
- A sequence of any of the above
|
| 108 |
"""
|
| 109 |
return FunctionPrompt.from_function(
|
| 110 |
+
fn=fn, name=name, description=description, tags=tags, enabled=enabled
|
| 111 |
)
|
| 112 |
|
| 113 |
@abstractmethod
|
|
|
|
| 131 |
name: str | None = None,
|
| 132 |
description: str | None = None,
|
| 133 |
tags: set[str] | None = None,
|
| 134 |
+
enabled: bool | None = None,
|
| 135 |
) -> FunctionPrompt:
|
| 136 |
"""Create a Prompt from a function.
|
| 137 |
|
|
|
|
| 197 |
description=description,
|
| 198 |
arguments=arguments,
|
| 199 |
tags=tags or set(),
|
| 200 |
+
enabled=enabled if enabled is not None else True,
|
| 201 |
fn=fn,
|
| 202 |
)
|
| 203 |
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -40,9 +40,11 @@ class PromptManager:
|
|
| 40 |
|
| 41 |
self.duplicate_behavior = duplicate_behavior
|
| 42 |
|
| 43 |
-
def get_prompt(self, key: str) -> Prompt
|
| 44 |
"""Get prompt by key."""
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
| 47 |
def get_prompts(self) -> dict[str, Prompt]:
|
| 48 |
"""Get all registered prompts, indexed by registered key."""
|
|
|
|
| 40 |
|
| 41 |
self.duplicate_behavior = duplicate_behavior
|
| 42 |
|
| 43 |
+
def get_prompt(self, key: str) -> Prompt:
|
| 44 |
"""Get prompt by key."""
|
| 45 |
+
if key in self._prompts:
|
| 46 |
+
return self._prompts[key]
|
| 47 |
+
raise NotFoundError(f"Unknown prompt: {key}")
|
| 48 |
|
| 49 |
def get_prompts(self) -> dict[str, Prompt]:
|
| 50 |
"""Get all registered prompts, indexed by registered key."""
|
src/fastmcp/resources/resource.py
CHANGED
|
@@ -52,6 +52,7 @@ class Resource(FastMCPComponent, abc.ABC):
|
|
| 52 |
description: str | None = None,
|
| 53 |
mime_type: str | None = None,
|
| 54 |
tags: set[str] | None = None,
|
|
|
|
| 55 |
) -> FunctionResource:
|
| 56 |
return FunctionResource.from_function(
|
| 57 |
fn=fn,
|
|
@@ -60,6 +61,7 @@ class Resource(FastMCPComponent, abc.ABC):
|
|
| 60 |
description=description,
|
| 61 |
mime_type=mime_type,
|
| 62 |
tags=tags,
|
|
|
|
| 63 |
)
|
| 64 |
|
| 65 |
@field_validator("mime_type", mode="before")
|
|
@@ -124,6 +126,7 @@ class FunctionResource(Resource):
|
|
| 124 |
description: str | None = None,
|
| 125 |
mime_type: str | None = None,
|
| 126 |
tags: set[str] | None = None,
|
|
|
|
| 127 |
) -> FunctionResource:
|
| 128 |
"""Create a FunctionResource from a function."""
|
| 129 |
if isinstance(uri, str):
|
|
@@ -135,6 +138,7 @@ class FunctionResource(Resource):
|
|
| 135 |
description=description or fn.__doc__,
|
| 136 |
mime_type=mime_type or "text/plain",
|
| 137 |
tags=tags or set(),
|
|
|
|
| 138 |
)
|
| 139 |
|
| 140 |
async def read(self) -> str | bytes:
|
|
|
|
| 52 |
description: str | None = None,
|
| 53 |
mime_type: str | None = None,
|
| 54 |
tags: set[str] | None = None,
|
| 55 |
+
enabled: bool | None = None,
|
| 56 |
) -> FunctionResource:
|
| 57 |
return FunctionResource.from_function(
|
| 58 |
fn=fn,
|
|
|
|
| 61 |
description=description,
|
| 62 |
mime_type=mime_type,
|
| 63 |
tags=tags,
|
| 64 |
+
enabled=enabled,
|
| 65 |
)
|
| 66 |
|
| 67 |
@field_validator("mime_type", mode="before")
|
|
|
|
| 126 |
description: str | None = None,
|
| 127 |
mime_type: str | None = None,
|
| 128 |
tags: set[str] | None = None,
|
| 129 |
+
enabled: bool | None = None,
|
| 130 |
) -> FunctionResource:
|
| 131 |
"""Create a FunctionResource from a function."""
|
| 132 |
if isinstance(uri, str):
|
|
|
|
| 138 |
description=description or fn.__doc__,
|
| 139 |
mime_type=mime_type or "text/plain",
|
| 140 |
tags=tags or set(),
|
| 141 |
+
enabled=enabled if enabled is not None else True,
|
| 142 |
)
|
| 143 |
|
| 144 |
async def read(self) -> str | bytes:
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -70,6 +70,7 @@ class ResourceTemplate(FastMCPComponent):
|
|
| 70 |
description: str | None = None,
|
| 71 |
mime_type: str | None = None,
|
| 72 |
tags: set[str] | None = None,
|
|
|
|
| 73 |
) -> FunctionResourceTemplate:
|
| 74 |
return FunctionResourceTemplate.from_function(
|
| 75 |
fn=fn,
|
|
@@ -78,6 +79,7 @@ class ResourceTemplate(FastMCPComponent):
|
|
| 78 |
description=description,
|
| 79 |
mime_type=mime_type,
|
| 80 |
tags=tags,
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
@field_validator("mime_type", mode="before")
|
|
@@ -113,6 +115,7 @@ class ResourceTemplate(FastMCPComponent):
|
|
| 113 |
description=self.description,
|
| 114 |
mime_type=self.mime_type,
|
| 115 |
tags=self.tags,
|
|
|
|
| 116 |
)
|
| 117 |
|
| 118 |
def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate:
|
|
@@ -155,6 +158,7 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
| 155 |
description: str | None = None,
|
| 156 |
mime_type: str | None = None,
|
| 157 |
tags: set[str] | None = None,
|
|
|
|
| 158 |
) -> FunctionResourceTemplate:
|
| 159 |
"""Create a template from a function."""
|
| 160 |
from fastmcp.server.context import Context
|
|
@@ -237,4 +241,5 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|
| 237 |
fn=fn,
|
| 238 |
parameters=parameters,
|
| 239 |
tags=tags or set(),
|
|
|
|
| 240 |
)
|
|
|
|
| 70 |
description: str | None = None,
|
| 71 |
mime_type: str | None = None,
|
| 72 |
tags: set[str] | None = None,
|
| 73 |
+
enabled: bool | None = None,
|
| 74 |
) -> FunctionResourceTemplate:
|
| 75 |
return FunctionResourceTemplate.from_function(
|
| 76 |
fn=fn,
|
|
|
|
| 79 |
description=description,
|
| 80 |
mime_type=mime_type,
|
| 81 |
tags=tags,
|
| 82 |
+
enabled=enabled,
|
| 83 |
)
|
| 84 |
|
| 85 |
@field_validator("mime_type", mode="before")
|
|
|
|
| 115 |
description=self.description,
|
| 116 |
mime_type=self.mime_type,
|
| 117 |
tags=self.tags,
|
| 118 |
+
enabled=self.enabled,
|
| 119 |
)
|
| 120 |
|
| 121 |
def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate:
|
|
|
|
| 158 |
description: str | None = None,
|
| 159 |
mime_type: str | None = None,
|
| 160 |
tags: set[str] | None = None,
|
| 161 |
+
enabled: bool | None = None,
|
| 162 |
) -> FunctionResourceTemplate:
|
| 163 |
"""Create a template from a function."""
|
| 164 |
from fastmcp.server.context import Context
|
|
|
|
| 241 |
fn=fn,
|
| 242 |
parameters=parameters,
|
| 243 |
tags=tags or set(),
|
| 244 |
+
enabled=enabled if enabled is not None else True,
|
| 245 |
)
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -241,20 +241,20 @@ class FastMCPProxy(FastMCP):
|
|
| 241 |
prompts[prompt_proxy.name] = prompt_proxy
|
| 242 |
return prompts
|
| 243 |
|
| 244 |
-
async def
|
| 245 |
self, key: str, arguments: dict[str, Any]
|
| 246 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 247 |
try:
|
| 248 |
-
result = await super().
|
| 249 |
return result
|
| 250 |
except NotFoundError:
|
| 251 |
async with self.client:
|
| 252 |
result = await self.client.call_tool(key, arguments)
|
| 253 |
return result
|
| 254 |
|
| 255 |
-
async def
|
| 256 |
try:
|
| 257 |
-
result = await super().
|
| 258 |
return result
|
| 259 |
except NotFoundError:
|
| 260 |
async with self.client:
|
|
@@ -270,11 +270,11 @@ class FastMCPProxy(FastMCP):
|
|
| 270 |
ReadResourceContents(content=content, mime_type=resource[0].mimeType)
|
| 271 |
]
|
| 272 |
|
| 273 |
-
async def
|
| 274 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 275 |
) -> GetPromptResult:
|
| 276 |
try:
|
| 277 |
-
result = await super().
|
| 278 |
return result
|
| 279 |
except NotFoundError:
|
| 280 |
async with self.client:
|
|
|
|
| 241 |
prompts[prompt_proxy.name] = prompt_proxy
|
| 242 |
return prompts
|
| 243 |
|
| 244 |
+
async def _call_tool(
|
| 245 |
self, key: str, arguments: dict[str, Any]
|
| 246 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 247 |
try:
|
| 248 |
+
result = await super()._call_tool(key, arguments)
|
| 249 |
return result
|
| 250 |
except NotFoundError:
|
| 251 |
async with self.client:
|
| 252 |
result = await self.client.call_tool(key, arguments)
|
| 253 |
return result
|
| 254 |
|
| 255 |
+
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 256 |
try:
|
| 257 |
+
result = await super()._read_resource(uri)
|
| 258 |
return result
|
| 259 |
except NotFoundError:
|
| 260 |
async with self.client:
|
|
|
|
| 270 |
ReadResourceContents(content=content, mime_type=resource[0].mimeType)
|
| 271 |
]
|
| 272 |
|
| 273 |
+
async def _get_prompt(
|
| 274 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 275 |
) -> GetPromptResult:
|
| 276 |
try:
|
| 277 |
+
result = await super()._get_prompt(name, arguments)
|
| 278 |
return result
|
| 279 |
except NotFoundError:
|
| 280 |
async with self.client:
|
src/fastmcp/server/server.py
CHANGED
|
@@ -44,7 +44,7 @@ from starlette.routing import BaseRoute, Route
|
|
| 44 |
import fastmcp
|
| 45 |
import fastmcp.server
|
| 46 |
import fastmcp.settings
|
| 47 |
-
from fastmcp.exceptions import NotFoundError
|
| 48 |
from fastmcp.prompts import Prompt, PromptManager
|
| 49 |
from fastmcp.prompts.prompt import FunctionPrompt
|
| 50 |
from fastmcp.resources import Resource, ResourceManager
|
|
@@ -291,6 +291,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 291 |
self._cache.set("resources", resources)
|
| 292 |
return resources
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 295 |
"""Get all registered resource templates, indexed by registered key."""
|
| 296 |
if (
|
|
@@ -311,6 +317,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 311 |
self._cache.set("resource_templates", templates)
|
| 312 |
return templates
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
async def get_prompts(self) -> dict[str, Prompt]:
|
| 315 |
"""
|
| 316 |
List all available prompts.
|
|
@@ -330,6 +342,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 330 |
self._cache.set("prompts", prompts)
|
| 331 |
return prompts
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
def custom_route(
|
| 334 |
self,
|
| 335 |
path: str,
|
|
@@ -381,7 +399,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 381 |
|
| 382 |
"""
|
| 383 |
tools = await self.get_tools()
|
| 384 |
-
return [
|
|
|
|
|
|
|
| 385 |
|
| 386 |
async def _mcp_list_resources(self) -> list[MCPResource]:
|
| 387 |
"""
|
|
@@ -391,7 +411,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 391 |
"""
|
| 392 |
resources = await self.get_resources()
|
| 393 |
return [
|
| 394 |
-
resource.to_mcp_resource(uri=key)
|
|
|
|
|
|
|
| 395 |
]
|
| 396 |
|
| 397 |
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
|
@@ -404,6 +426,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 404 |
return [
|
| 405 |
template.to_mcp_template(uriTemplate=key)
|
| 406 |
for key, template in templates.items()
|
|
|
|
| 407 |
]
|
| 408 |
|
| 409 |
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
|
@@ -413,12 +436,19 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 413 |
|
| 414 |
"""
|
| 415 |
prompts = await self.get_prompts()
|
| 416 |
-
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
async def _mcp_call_tool(
|
| 419 |
self, key: str, arguments: dict[str, Any]
|
| 420 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 421 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
Args:
|
| 424 |
key: The name of the tool to call
|
|
@@ -431,43 +461,109 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 431 |
|
| 432 |
# Create and use context for the entire call
|
| 433 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
tool_key = server.strip_tool_prefix(key)
|
| 442 |
-
return await server.server._mcp_call_tool(tool_key, arguments)
|
| 443 |
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
| 446 |
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
"""
|
| 448 |
Read a resource by URI, in the format expected by the low-level MCP
|
| 449 |
server.
|
| 450 |
"""
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
else:
|
| 462 |
-
|
| 463 |
-
if server.match_resource(str(uri)):
|
| 464 |
-
new_uri = server.strip_resource_prefix(str(uri))
|
| 465 |
-
return await server.server._mcp_read_resource(new_uri)
|
| 466 |
-
else:
|
| 467 |
-
raise NotFoundError(f"Unknown resource: {uri}")
|
| 468 |
|
| 469 |
async def _mcp_get_prompt(
|
| 470 |
self, name: str, arguments: dict[str, Any] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
) -> GetPromptResult:
|
| 472 |
"""Handle MCP 'getPrompt' requests.
|
| 473 |
|
|
@@ -480,19 +576,20 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 480 |
"""
|
| 481 |
logger.debug("Get prompt: %s with %s", name, arguments)
|
| 482 |
|
| 483 |
-
#
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
if
|
| 487 |
-
|
|
|
|
| 488 |
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
|
| 495 |
-
|
| 496 |
|
| 497 |
def add_tool(self, tool: Tool) -> None:
|
| 498 |
"""Add a tool to the server.
|
|
@@ -528,6 +625,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 528 |
tags: set[str] | None = None,
|
| 529 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 530 |
exclude_args: list[str] | None = None,
|
|
|
|
| 531 |
) -> FunctionTool: ...
|
| 532 |
|
| 533 |
@overload
|
|
@@ -540,6 +638,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 540 |
tags: set[str] | None = None,
|
| 541 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 542 |
exclude_args: list[str] | None = None,
|
|
|
|
| 543 |
) -> Callable[[AnyFunction], FunctionTool]: ...
|
| 544 |
|
| 545 |
def tool(
|
|
@@ -551,6 +650,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 551 |
tags: set[str] | None = None,
|
| 552 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 553 |
exclude_args: list[str] | None = None,
|
|
|
|
| 554 |
) -> Callable[[AnyFunction], FunctionTool] | FunctionTool:
|
| 555 |
"""Decorator to register a tool.
|
| 556 |
|
|
@@ -567,11 +667,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 567 |
|
| 568 |
Args:
|
| 569 |
name_or_fn: Either a function (when used as @tool), a string name, or None
|
|
|
|
| 570 |
description: Optional description of what the tool does
|
| 571 |
tags: Optional set of tags for categorizing the tool
|
| 572 |
-
annotations: Optional annotations about the tool's behavior
|
| 573 |
exclude_args: Optional list of argument names to exclude from the tool schema
|
| 574 |
-
|
| 575 |
|
| 576 |
Example:
|
| 577 |
@server.tool
|
|
@@ -624,6 +725,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 624 |
annotations=annotations,
|
| 625 |
exclude_args=exclude_args,
|
| 626 |
serializer=self._tool_serializer,
|
|
|
|
| 627 |
)
|
| 628 |
self.add_tool(tool)
|
| 629 |
return tool
|
|
@@ -652,6 +754,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 652 |
tags=tags,
|
| 653 |
annotations=annotations,
|
| 654 |
exclude_args=exclude_args,
|
|
|
|
| 655 |
)
|
| 656 |
|
| 657 |
def add_resource(self, resource: Resource, key: str | None = None) -> None:
|
|
@@ -718,6 +821,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 718 |
description: str | None = None,
|
| 719 |
mime_type: str | None = None,
|
| 720 |
tags: set[str] | None = None,
|
|
|
|
| 721 |
) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
|
| 722 |
"""Decorator to register a function as a resource.
|
| 723 |
|
|
@@ -740,6 +844,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 740 |
description: Optional description of the resource
|
| 741 |
mime_type: Optional MIME type for the resource
|
| 742 |
tags: Optional set of tags for categorizing the resource
|
|
|
|
| 743 |
|
| 744 |
Example:
|
| 745 |
@server.resource("resource://my-resource")
|
|
@@ -804,6 +909,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 804 |
description=description,
|
| 805 |
mime_type=mime_type,
|
| 806 |
tags=tags,
|
|
|
|
| 807 |
)
|
| 808 |
self.add_template(template)
|
| 809 |
return template
|
|
@@ -815,6 +921,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 815 |
description=description,
|
| 816 |
mime_type=mime_type,
|
| 817 |
tags=tags,
|
|
|
|
| 818 |
)
|
| 819 |
self.add_resource(resource)
|
| 820 |
return resource
|
|
@@ -843,6 +950,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 843 |
name: str | None = None,
|
| 844 |
description: str | None = None,
|
| 845 |
tags: set[str] | None = None,
|
|
|
|
| 846 |
) -> FunctionPrompt: ...
|
| 847 |
|
| 848 |
@overload
|
|
@@ -853,6 +961,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 853 |
name: str | None = None,
|
| 854 |
description: str | None = None,
|
| 855 |
tags: set[str] | None = None,
|
|
|
|
| 856 |
) -> Callable[[AnyFunction], FunctionPrompt]: ...
|
| 857 |
|
| 858 |
def prompt(
|
|
@@ -862,6 +971,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 862 |
name: str | None = None,
|
| 863 |
description: str | None = None,
|
| 864 |
tags: set[str] | None = None,
|
|
|
|
| 865 |
) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt:
|
| 866 |
"""Decorator to register a prompt.
|
| 867 |
|
|
@@ -878,9 +988,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 878 |
|
| 879 |
Args:
|
| 880 |
name_or_fn: Either a function (when used as @prompt), a string name, or None
|
|
|
|
| 881 |
description: Optional description of what the prompt does
|
| 882 |
tags: Optional set of tags for categorizing the prompt
|
| 883 |
-
|
| 884 |
|
| 885 |
Example:
|
| 886 |
@server.prompt
|
|
@@ -953,6 +1064,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 953 |
name=prompt_name,
|
| 954 |
description=description,
|
| 955 |
tags=tags,
|
|
|
|
| 956 |
)
|
| 957 |
self.add_prompt(prompt)
|
| 958 |
|
|
@@ -980,6 +1092,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 980 |
name=prompt_name,
|
| 981 |
description=description,
|
| 982 |
tags=tags,
|
|
|
|
| 983 |
)
|
| 984 |
|
| 985 |
async def run_stdio_async(self) -> None:
|
|
|
|
| 44 |
import fastmcp
|
| 45 |
import fastmcp.server
|
| 46 |
import fastmcp.settings
|
| 47 |
+
from fastmcp.exceptions import DisabledError, NotFoundError
|
| 48 |
from fastmcp.prompts import Prompt, PromptManager
|
| 49 |
from fastmcp.prompts.prompt import FunctionPrompt
|
| 50 |
from fastmcp.resources import Resource, ResourceManager
|
|
|
|
| 291 |
self._cache.set("resources", resources)
|
| 292 |
return resources
|
| 293 |
|
| 294 |
+
async def get_resource(self, key: str) -> Resource:
|
| 295 |
+
resources = await self.get_resources()
|
| 296 |
+
if key not in resources:
|
| 297 |
+
raise NotFoundError(f"Unknown resource: {key}")
|
| 298 |
+
return resources[key]
|
| 299 |
+
|
| 300 |
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
|
| 301 |
"""Get all registered resource templates, indexed by registered key."""
|
| 302 |
if (
|
|
|
|
| 317 |
self._cache.set("resource_templates", templates)
|
| 318 |
return templates
|
| 319 |
|
| 320 |
+
async def get_resource_template(self, key: str) -> ResourceTemplate:
|
| 321 |
+
templates = await self.get_resource_templates()
|
| 322 |
+
if key not in templates:
|
| 323 |
+
raise NotFoundError(f"Unknown resource template: {key}")
|
| 324 |
+
return templates[key]
|
| 325 |
+
|
| 326 |
async def get_prompts(self) -> dict[str, Prompt]:
|
| 327 |
"""
|
| 328 |
List all available prompts.
|
|
|
|
| 342 |
self._cache.set("prompts", prompts)
|
| 343 |
return prompts
|
| 344 |
|
| 345 |
+
async def get_prompt(self, key: str) -> Prompt:
|
| 346 |
+
prompts = await self.get_prompts()
|
| 347 |
+
if key not in prompts:
|
| 348 |
+
raise NotFoundError(f"Unknown prompt: {key}")
|
| 349 |
+
return prompts[key]
|
| 350 |
+
|
| 351 |
def custom_route(
|
| 352 |
self,
|
| 353 |
path: str,
|
|
|
|
| 399 |
|
| 400 |
"""
|
| 401 |
tools = await self.get_tools()
|
| 402 |
+
return [
|
| 403 |
+
tool.to_mcp_tool(name=key) for key, tool in tools.items() if tool.enabled
|
| 404 |
+
]
|
| 405 |
|
| 406 |
async def _mcp_list_resources(self) -> list[MCPResource]:
|
| 407 |
"""
|
|
|
|
| 411 |
"""
|
| 412 |
resources = await self.get_resources()
|
| 413 |
return [
|
| 414 |
+
resource.to_mcp_resource(uri=key)
|
| 415 |
+
for key, resource in resources.items()
|
| 416 |
+
if resource.enabled
|
| 417 |
]
|
| 418 |
|
| 419 |
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
|
|
|
|
| 426 |
return [
|
| 427 |
template.to_mcp_template(uriTemplate=key)
|
| 428 |
for key, template in templates.items()
|
| 429 |
+
if template.enabled
|
| 430 |
]
|
| 431 |
|
| 432 |
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
|
|
|
|
| 436 |
|
| 437 |
"""
|
| 438 |
prompts = await self.get_prompts()
|
| 439 |
+
return [
|
| 440 |
+
prompt.to_mcp_prompt(name=key)
|
| 441 |
+
for key, prompt in prompts.items()
|
| 442 |
+
if prompt.enabled
|
| 443 |
+
]
|
| 444 |
|
| 445 |
async def _mcp_call_tool(
|
| 446 |
self, key: str, arguments: dict[str, Any]
|
| 447 |
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 448 |
+
"""
|
| 449 |
+
Handle MCP 'callTool' requests.
|
| 450 |
+
|
| 451 |
+
Delegates to _call_tool, which should be overridden by FastMCP subclasses.
|
| 452 |
|
| 453 |
Args:
|
| 454 |
key: The name of the tool to call
|
|
|
|
| 461 |
|
| 462 |
# Create and use context for the entire call
|
| 463 |
with fastmcp.server.context.Context(fastmcp=self):
|
| 464 |
+
try:
|
| 465 |
+
return await self._call_tool(key, arguments)
|
| 466 |
+
except DisabledError:
|
| 467 |
+
# convert to NotFoundError to avoid leaking tool presence
|
| 468 |
+
raise NotFoundError(f"Unknown tool: {key}")
|
| 469 |
+
except NotFoundError:
|
| 470 |
+
# standardize NotFound message
|
| 471 |
+
raise NotFoundError(f"Unknown tool: {key}")
|
| 472 |
+
|
| 473 |
+
async def _call_tool(
|
| 474 |
+
self, key: str, arguments: dict[str, Any]
|
| 475 |
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 476 |
+
"""
|
| 477 |
+
Call a tool with raw MCP arguments. FastMCP subclasses should override
|
| 478 |
+
this method, not _mcp_call_tool.
|
| 479 |
|
| 480 |
+
Args:
|
| 481 |
+
key: The name of the tool to call arguments: Arguments to pass to
|
| 482 |
+
the tool
|
|
|
|
|
|
|
| 483 |
|
| 484 |
+
Returns:
|
| 485 |
+
List of MCP Content objects containing the tool results
|
| 486 |
+
"""
|
| 487 |
+
|
| 488 |
+
# Get tool, checking first from our tools, then from the mounted servers
|
| 489 |
+
if self._tool_manager.has_tool(key):
|
| 490 |
+
tool = self._tool_manager.get_tool(key)
|
| 491 |
+
if not tool.enabled:
|
| 492 |
+
raise DisabledError(f"Tool {key!r} is disabled")
|
| 493 |
+
return await self._tool_manager.call_tool(key, arguments)
|
| 494 |
+
|
| 495 |
+
# Check mounted servers to see if they have the tool
|
| 496 |
+
for server in self._mounted_servers.values():
|
| 497 |
+
if server.match_tool(key):
|
| 498 |
+
tool_key = server.strip_tool_prefix(key)
|
| 499 |
+
return await server.server._call_tool(tool_key, arguments)
|
| 500 |
+
|
| 501 |
+
raise NotFoundError(f"Unknown tool: {key!r}")
|
| 502 |
|
| 503 |
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 504 |
+
"""
|
| 505 |
+
Handle MCP 'readResource' requests.
|
| 506 |
+
|
| 507 |
+
Delegates to _read_resource, which should be overridden by FastMCP subclasses.
|
| 508 |
+
"""
|
| 509 |
+
logger.debug("Read resource: %s", uri)
|
| 510 |
+
|
| 511 |
+
with fastmcp.server.context.Context(fastmcp=self):
|
| 512 |
+
try:
|
| 513 |
+
return await self._read_resource(uri)
|
| 514 |
+
except DisabledError:
|
| 515 |
+
# convert to NotFoundError to avoid leaking resource presence
|
| 516 |
+
raise NotFoundError(f"Unknown resource: {str(uri)!r}")
|
| 517 |
+
except NotFoundError:
|
| 518 |
+
# standardize NotFound message
|
| 519 |
+
raise NotFoundError(f"Unknown resource: {str(uri)!r}")
|
| 520 |
+
|
| 521 |
+
async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
| 522 |
"""
|
| 523 |
Read a resource by URI, in the format expected by the low-level MCP
|
| 524 |
server.
|
| 525 |
"""
|
| 526 |
+
if self._resource_manager.has_resource(uri):
|
| 527 |
+
resource = await self._resource_manager.get_resource(uri)
|
| 528 |
+
if not resource.enabled:
|
| 529 |
+
raise DisabledError(f"Resource {str(uri)!r} is disabled")
|
| 530 |
+
content = await self._resource_manager.read_resource(uri)
|
| 531 |
+
return [
|
| 532 |
+
ReadResourceContents(
|
| 533 |
+
content=content,
|
| 534 |
+
mime_type=resource.mime_type,
|
| 535 |
+
)
|
| 536 |
+
]
|
| 537 |
+
else:
|
| 538 |
+
for server in self._mounted_servers.values():
|
| 539 |
+
if server.match_resource(str(uri)):
|
| 540 |
+
new_uri = server.strip_resource_prefix(str(uri))
|
| 541 |
+
return await server.server._mcp_read_resource(new_uri)
|
| 542 |
else:
|
| 543 |
+
raise NotFoundError(f"Unknown resource: {uri}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
|
| 545 |
async def _mcp_get_prompt(
|
| 546 |
self, name: str, arguments: dict[str, Any] | None = None
|
| 547 |
+
) -> GetPromptResult:
|
| 548 |
+
"""
|
| 549 |
+
Handle MCP 'getPrompt' requests.
|
| 550 |
+
|
| 551 |
+
Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
|
| 552 |
+
"""
|
| 553 |
+
logger.debug("Get prompt: %s with %s", name, arguments)
|
| 554 |
+
|
| 555 |
+
with fastmcp.server.context.Context(fastmcp=self):
|
| 556 |
+
try:
|
| 557 |
+
return await self._get_prompt(name, arguments)
|
| 558 |
+
except DisabledError:
|
| 559 |
+
# convert to NotFoundError to avoid leaking prompt presence
|
| 560 |
+
raise NotFoundError(f"Unknown prompt: {name}")
|
| 561 |
+
except NotFoundError:
|
| 562 |
+
# standardize NotFound message
|
| 563 |
+
raise NotFoundError(f"Unknown prompt: {name}")
|
| 564 |
+
|
| 565 |
+
async def _get_prompt(
|
| 566 |
+
self, name: str, arguments: dict[str, Any] | None = None
|
| 567 |
) -> GetPromptResult:
|
| 568 |
"""Handle MCP 'getPrompt' requests.
|
| 569 |
|
|
|
|
| 576 |
"""
|
| 577 |
logger.debug("Get prompt: %s with %s", name, arguments)
|
| 578 |
|
| 579 |
+
# Get prompt, checking first from our prompts, then from the mounted servers
|
| 580 |
+
if self._prompt_manager.has_prompt(name):
|
| 581 |
+
prompt = self._prompt_manager.get_prompt(name)
|
| 582 |
+
if not prompt.enabled:
|
| 583 |
+
raise DisabledError(f"Prompt {name!r} is disabled")
|
| 584 |
+
return await self._prompt_manager.render_prompt(name, arguments)
|
| 585 |
|
| 586 |
+
# Check mounted servers to see if they have the prompt
|
| 587 |
+
for server in self._mounted_servers.values():
|
| 588 |
+
if server.match_prompt(name):
|
| 589 |
+
prompt_name = server.strip_prompt_prefix(name)
|
| 590 |
+
return await server.server._mcp_get_prompt(prompt_name, arguments)
|
| 591 |
|
| 592 |
+
raise NotFoundError(f"Unknown prompt: {name}")
|
| 593 |
|
| 594 |
def add_tool(self, tool: Tool) -> None:
|
| 595 |
"""Add a tool to the server.
|
|
|
|
| 625 |
tags: set[str] | None = None,
|
| 626 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 627 |
exclude_args: list[str] | None = None,
|
| 628 |
+
enabled: bool | None = None,
|
| 629 |
) -> FunctionTool: ...
|
| 630 |
|
| 631 |
@overload
|
|
|
|
| 638 |
tags: set[str] | None = None,
|
| 639 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 640 |
exclude_args: list[str] | None = None,
|
| 641 |
+
enabled: bool | None = None,
|
| 642 |
) -> Callable[[AnyFunction], FunctionTool]: ...
|
| 643 |
|
| 644 |
def tool(
|
|
|
|
| 650 |
tags: set[str] | None = None,
|
| 651 |
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
| 652 |
exclude_args: list[str] | None = None,
|
| 653 |
+
enabled: bool | None = None,
|
| 654 |
) -> Callable[[AnyFunction], FunctionTool] | FunctionTool:
|
| 655 |
"""Decorator to register a tool.
|
| 656 |
|
|
|
|
| 667 |
|
| 668 |
Args:
|
| 669 |
name_or_fn: Either a function (when used as @tool), a string name, or None
|
| 670 |
+
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
|
| 671 |
description: Optional description of what the tool does
|
| 672 |
tags: Optional set of tags for categorizing the tool
|
| 673 |
+
annotations: Optional annotations about the tool's behavior (e.g. {"is_async": True})
|
| 674 |
exclude_args: Optional list of argument names to exclude from the tool schema
|
| 675 |
+
enabled: Optional boolean to enable or disable the tool
|
| 676 |
|
| 677 |
Example:
|
| 678 |
@server.tool
|
|
|
|
| 725 |
annotations=annotations,
|
| 726 |
exclude_args=exclude_args,
|
| 727 |
serializer=self._tool_serializer,
|
| 728 |
+
enabled=enabled,
|
| 729 |
)
|
| 730 |
self.add_tool(tool)
|
| 731 |
return tool
|
|
|
|
| 754 |
tags=tags,
|
| 755 |
annotations=annotations,
|
| 756 |
exclude_args=exclude_args,
|
| 757 |
+
enabled=enabled,
|
| 758 |
)
|
| 759 |
|
| 760 |
def add_resource(self, resource: Resource, key: str | None = None) -> None:
|
|
|
|
| 821 |
description: str | None = None,
|
| 822 |
mime_type: str | None = None,
|
| 823 |
tags: set[str] | None = None,
|
| 824 |
+
enabled: bool | None = None,
|
| 825 |
) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
|
| 826 |
"""Decorator to register a function as a resource.
|
| 827 |
|
|
|
|
| 844 |
description: Optional description of the resource
|
| 845 |
mime_type: Optional MIME type for the resource
|
| 846 |
tags: Optional set of tags for categorizing the resource
|
| 847 |
+
enabled: Optional boolean to enable or disable the resource
|
| 848 |
|
| 849 |
Example:
|
| 850 |
@server.resource("resource://my-resource")
|
|
|
|
| 909 |
description=description,
|
| 910 |
mime_type=mime_type,
|
| 911 |
tags=tags,
|
| 912 |
+
enabled=enabled,
|
| 913 |
)
|
| 914 |
self.add_template(template)
|
| 915 |
return template
|
|
|
|
| 921 |
description=description,
|
| 922 |
mime_type=mime_type,
|
| 923 |
tags=tags,
|
| 924 |
+
enabled=enabled,
|
| 925 |
)
|
| 926 |
self.add_resource(resource)
|
| 927 |
return resource
|
|
|
|
| 950 |
name: str | None = None,
|
| 951 |
description: str | None = None,
|
| 952 |
tags: set[str] | None = None,
|
| 953 |
+
enabled: bool | None = None,
|
| 954 |
) -> FunctionPrompt: ...
|
| 955 |
|
| 956 |
@overload
|
|
|
|
| 961 |
name: str | None = None,
|
| 962 |
description: str | None = None,
|
| 963 |
tags: set[str] | None = None,
|
| 964 |
+
enabled: bool | None = None,
|
| 965 |
) -> Callable[[AnyFunction], FunctionPrompt]: ...
|
| 966 |
|
| 967 |
def prompt(
|
|
|
|
| 971 |
name: str | None = None,
|
| 972 |
description: str | None = None,
|
| 973 |
tags: set[str] | None = None,
|
| 974 |
+
enabled: bool | None = None,
|
| 975 |
) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt:
|
| 976 |
"""Decorator to register a prompt.
|
| 977 |
|
|
|
|
| 988 |
|
| 989 |
Args:
|
| 990 |
name_or_fn: Either a function (when used as @prompt), a string name, or None
|
| 991 |
+
name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
|
| 992 |
description: Optional description of what the prompt does
|
| 993 |
tags: Optional set of tags for categorizing the prompt
|
| 994 |
+
enabled: Optional boolean to enable or disable the prompt
|
| 995 |
|
| 996 |
Example:
|
| 997 |
@server.prompt
|
|
|
|
| 1064 |
name=prompt_name,
|
| 1065 |
description=description,
|
| 1066 |
tags=tags,
|
| 1067 |
+
enabled=enabled,
|
| 1068 |
)
|
| 1069 |
self.add_prompt(prompt)
|
| 1070 |
|
|
|
|
| 1092 |
name=prompt_name,
|
| 1093 |
description=description,
|
| 1094 |
tags=tags,
|
| 1095 |
+
enabled=enabled,
|
| 1096 |
)
|
| 1097 |
|
| 1098 |
async def run_stdio_async(self) -> None:
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -62,6 +62,7 @@ class Tool(FastMCPComponent, ABC):
|
|
| 62 |
annotations: ToolAnnotations | None = None,
|
| 63 |
exclude_args: list[str] | None = None,
|
| 64 |
serializer: Callable[[Any], str] | None = None,
|
|
|
|
| 65 |
) -> FunctionTool:
|
| 66 |
"""Create a Tool from a function."""
|
| 67 |
return FunctionTool.from_function(
|
|
@@ -72,6 +73,7 @@ class Tool(FastMCPComponent, ABC):
|
|
| 72 |
annotations=annotations,
|
| 73 |
exclude_args=exclude_args,
|
| 74 |
serializer=serializer,
|
|
|
|
| 75 |
)
|
| 76 |
|
| 77 |
@abstractmethod
|
|
@@ -92,6 +94,7 @@ class Tool(FastMCPComponent, ABC):
|
|
| 92 |
tags: set[str] | None = None,
|
| 93 |
annotations: ToolAnnotations | None = None,
|
| 94 |
serializer: Callable[[Any], str] | None = None,
|
|
|
|
| 95 |
) -> TransformedTool:
|
| 96 |
from fastmcp.tools.tool_transform import TransformedTool
|
| 97 |
|
|
@@ -104,6 +107,7 @@ class Tool(FastMCPComponent, ABC):
|
|
| 104 |
tags=tags,
|
| 105 |
annotations=annotations,
|
| 106 |
serializer=serializer,
|
|
|
|
| 107 |
)
|
| 108 |
|
| 109 |
|
|
@@ -120,6 +124,7 @@ class FunctionTool(Tool):
|
|
| 120 |
annotations: ToolAnnotations | None = None,
|
| 121 |
exclude_args: list[str] | None = None,
|
| 122 |
serializer: Callable[[Any], str] | None = None,
|
|
|
|
| 123 |
) -> FunctionTool:
|
| 124 |
"""Create a Tool from a function."""
|
| 125 |
|
|
@@ -136,6 +141,7 @@ class FunctionTool(Tool):
|
|
| 136 |
tags=tags or set(),
|
| 137 |
annotations=annotations,
|
| 138 |
serializer=serializer,
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
async def run(
|
|
|
|
| 62 |
annotations: ToolAnnotations | None = None,
|
| 63 |
exclude_args: list[str] | None = None,
|
| 64 |
serializer: Callable[[Any], str] | None = None,
|
| 65 |
+
enabled: bool | None = None,
|
| 66 |
) -> FunctionTool:
|
| 67 |
"""Create a Tool from a function."""
|
| 68 |
return FunctionTool.from_function(
|
|
|
|
| 73 |
annotations=annotations,
|
| 74 |
exclude_args=exclude_args,
|
| 75 |
serializer=serializer,
|
| 76 |
+
enabled=enabled,
|
| 77 |
)
|
| 78 |
|
| 79 |
@abstractmethod
|
|
|
|
| 94 |
tags: set[str] | None = None,
|
| 95 |
annotations: ToolAnnotations | None = None,
|
| 96 |
serializer: Callable[[Any], str] | None = None,
|
| 97 |
+
enabled: bool | None = None,
|
| 98 |
) -> TransformedTool:
|
| 99 |
from fastmcp.tools.tool_transform import TransformedTool
|
| 100 |
|
|
|
|
| 107 |
tags=tags,
|
| 108 |
annotations=annotations,
|
| 109 |
serializer=serializer,
|
| 110 |
+
enabled=enabled,
|
| 111 |
)
|
| 112 |
|
| 113 |
|
|
|
|
| 124 |
annotations: ToolAnnotations | None = None,
|
| 125 |
exclude_args: list[str] | None = None,
|
| 126 |
serializer: Callable[[Any], str] | None = None,
|
| 127 |
+
enabled: bool | None = None,
|
| 128 |
) -> FunctionTool:
|
| 129 |
"""Create a Tool from a function."""
|
| 130 |
|
|
|
|
| 141 |
tags=tags or set(),
|
| 142 |
annotations=annotations,
|
| 143 |
serializer=serializer,
|
| 144 |
+
enabled=enabled if enabled is not None else True,
|
| 145 |
)
|
| 146 |
|
| 147 |
async def run(
|
src/fastmcp/tools/tool_transform.py
CHANGED
|
@@ -267,6 +267,7 @@ class TransformedTool(Tool):
|
|
| 267 |
transform_args: dict[str, ArgTransform] | None = None,
|
| 268 |
annotations: ToolAnnotations | None = None,
|
| 269 |
serializer: Callable[[Any], str] | None = None,
|
|
|
|
| 270 |
) -> TransformedTool:
|
| 271 |
"""Create a transformed tool from a parent tool.
|
| 272 |
|
|
@@ -399,6 +400,7 @@ class TransformedTool(Tool):
|
|
| 399 |
annotations=annotations or tool.annotations,
|
| 400 |
serializer=serializer or tool.serializer,
|
| 401 |
transform_args=transform_args,
|
|
|
|
| 402 |
)
|
| 403 |
|
| 404 |
return transformed_tool
|
|
|
|
| 267 |
transform_args: dict[str, ArgTransform] | None = None,
|
| 268 |
annotations: ToolAnnotations | None = None,
|
| 269 |
serializer: Callable[[Any], str] | None = None,
|
| 270 |
+
enabled: bool | None = None,
|
| 271 |
) -> TransformedTool:
|
| 272 |
"""Create a transformed tool from a parent tool.
|
| 273 |
|
|
|
|
| 400 |
annotations=annotations or tool.annotations,
|
| 401 |
serializer=serializer or tool.serializer,
|
| 402 |
transform_args=transform_args,
|
| 403 |
+
enabled=enabled if enabled is not None else True,
|
| 404 |
)
|
| 405 |
|
| 406 |
return transformed_tool
|
src/fastmcp/utilities/components.py
CHANGED
|
@@ -32,6 +32,11 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
| 32 |
description="Tags for the component.",
|
| 33 |
)
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def __eq__(self, other: object) -> bool:
|
| 36 |
if type(self) is not type(other):
|
| 37 |
return False
|
|
@@ -39,4 +44,12 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
| 39 |
return self.model_dump() == other.model_dump()
|
| 40 |
|
| 41 |
def __repr__(self) -> str:
|
| 42 |
-
return f"{self.__class__.__name__}(name={self.name!r}, description={self.description!r}, tags={self.tags})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
description="Tags for the component.",
|
| 33 |
)
|
| 34 |
|
| 35 |
+
enabled: bool = Field(
|
| 36 |
+
default=True,
|
| 37 |
+
description="Whether the component is enabled.",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
def __eq__(self, other: object) -> bool:
|
| 41 |
if type(self) is not type(other):
|
| 42 |
return False
|
|
|
|
| 44 |
return self.model_dump() == other.model_dump()
|
| 45 |
|
| 46 |
def __repr__(self) -> str:
|
| 47 |
+
return f"{self.__class__.__name__}(name={self.name!r}, description={self.description!r}, tags={self.tags}, enabled={self.enabled})"
|
| 48 |
+
|
| 49 |
+
def enable(self) -> None:
|
| 50 |
+
"""Enable the component."""
|
| 51 |
+
self.enabled = True
|
| 52 |
+
|
| 53 |
+
def disable(self) -> None:
|
| 54 |
+
"""Disable the component."""
|
| 55 |
+
self.enabled = False
|
tests/server/test_proxy.py
CHANGED
|
@@ -178,7 +178,9 @@ class TestResources:
|
|
| 178 |
assert json.loads(result[0].text) == USERS # type: ignore[attr-defined]
|
| 179 |
|
| 180 |
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
|
| 181 |
-
with pytest.raises(
|
|
|
|
|
|
|
| 182 |
async with Client(proxy_server) as client:
|
| 183 |
await client.read_resource("resource://nonexistent")
|
| 184 |
|
|
|
|
| 178 |
assert json.loads(result[0].text) == USERS # type: ignore[attr-defined]
|
| 179 |
|
| 180 |
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
|
| 181 |
+
with pytest.raises(
|
| 182 |
+
McpError, match="Unknown resource: 'resource://nonexistent'"
|
| 183 |
+
):
|
| 184 |
async with Client(proxy_server) as client:
|
| 185 |
await client.read_resource("resource://nonexistent")
|
| 186 |
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -699,6 +699,102 @@ class TestToolContextInjection:
|
|
| 699 |
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 700 |
|
| 701 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
class TestResource:
|
| 703 |
async def test_text_resource(self):
|
| 704 |
mcp = FastMCP()
|
|
@@ -783,6 +879,102 @@ class TestResourceContext:
|
|
| 783 |
assert result[0].text == "1" # type: ignore[attr-defined]
|
| 784 |
|
| 785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
class TestResourceTemplates:
|
| 787 |
async def test_resource_with_params_not_in_uri(self):
|
| 788 |
"""Test that a resource with function parameters raises an error if the URI
|
|
@@ -1034,6 +1226,99 @@ class TestResourceTemplateContext:
|
|
| 1034 |
assert result[0].text.startswith("Resource template: test 1") # type: ignore[attr-defined]
|
| 1035 |
|
| 1036 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
class TestPrompts:
|
| 1038 |
"""Test prompt functionality in FastMCP server."""
|
| 1039 |
|
|
@@ -1220,6 +1505,102 @@ class TestPrompts:
|
|
| 1220 |
assert prompt.tags == {"example", "test-tag"}
|
| 1221 |
|
| 1222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1223 |
class TestPromptContext:
|
| 1224 |
async def test_prompt_context(self):
|
| 1225 |
mcp = FastMCP()
|
|
|
|
| 699 |
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 700 |
|
| 701 |
|
| 702 |
+
class TestToolEnabled:
|
| 703 |
+
async def test_toggle_enabled(self):
|
| 704 |
+
mcp = FastMCP()
|
| 705 |
+
|
| 706 |
+
@mcp.tool
|
| 707 |
+
def sample_tool(x: int) -> int:
|
| 708 |
+
return x * 2
|
| 709 |
+
|
| 710 |
+
assert sample_tool.enabled
|
| 711 |
+
|
| 712 |
+
tool = await mcp.get_tool("sample_tool")
|
| 713 |
+
assert tool.enabled
|
| 714 |
+
|
| 715 |
+
tool.disable()
|
| 716 |
+
|
| 717 |
+
assert not tool.enabled
|
| 718 |
+
assert not sample_tool.enabled
|
| 719 |
+
|
| 720 |
+
tool.enable()
|
| 721 |
+
assert tool.enabled
|
| 722 |
+
assert sample_tool.enabled
|
| 723 |
+
|
| 724 |
+
async def test_tool_disabled_in_decorator(self):
|
| 725 |
+
mcp = FastMCP()
|
| 726 |
+
|
| 727 |
+
@mcp.tool(enabled=False)
|
| 728 |
+
def sample_tool(x: int) -> int:
|
| 729 |
+
return x * 2
|
| 730 |
+
|
| 731 |
+
async with Client(mcp) as client:
|
| 732 |
+
tools = await client.list_tools()
|
| 733 |
+
assert len(tools) == 0
|
| 734 |
+
|
| 735 |
+
with pytest.raises(ToolError, match="Unknown tool"):
|
| 736 |
+
await client.call_tool("sample_tool", {"x": 5})
|
| 737 |
+
|
| 738 |
+
async def test_tool_toggle_enabled(self):
|
| 739 |
+
mcp = FastMCP()
|
| 740 |
+
|
| 741 |
+
@mcp.tool(enabled=False)
|
| 742 |
+
def sample_tool(x: int) -> int:
|
| 743 |
+
return x * 2
|
| 744 |
+
|
| 745 |
+
sample_tool.enable()
|
| 746 |
+
|
| 747 |
+
async with Client(mcp) as client:
|
| 748 |
+
tools = await client.list_tools()
|
| 749 |
+
assert len(tools) == 1
|
| 750 |
+
|
| 751 |
+
async def test_tool_toggle_disabled(self):
|
| 752 |
+
mcp = FastMCP()
|
| 753 |
+
|
| 754 |
+
@mcp.tool
|
| 755 |
+
def sample_tool(x: int) -> int:
|
| 756 |
+
return x * 2
|
| 757 |
+
|
| 758 |
+
sample_tool.disable()
|
| 759 |
+
|
| 760 |
+
async with Client(mcp) as client:
|
| 761 |
+
tools = await client.list_tools()
|
| 762 |
+
assert len(tools) == 0
|
| 763 |
+
|
| 764 |
+
with pytest.raises(ToolError, match="Unknown tool"):
|
| 765 |
+
await client.call_tool("sample_tool", {"x": 5})
|
| 766 |
+
|
| 767 |
+
async def test_get_tool_and_disable(self):
|
| 768 |
+
mcp = FastMCP()
|
| 769 |
+
|
| 770 |
+
@mcp.tool
|
| 771 |
+
def sample_tool(x: int) -> int:
|
| 772 |
+
return x * 2
|
| 773 |
+
|
| 774 |
+
tool = await mcp.get_tool("sample_tool")
|
| 775 |
+
assert tool.enabled
|
| 776 |
+
|
| 777 |
+
sample_tool.disable()
|
| 778 |
+
|
| 779 |
+
async with Client(mcp) as client:
|
| 780 |
+
result = await client.list_tools()
|
| 781 |
+
assert len(result) == 0
|
| 782 |
+
|
| 783 |
+
with pytest.raises(ToolError, match="Unknown tool"):
|
| 784 |
+
await client.call_tool("sample_tool", {"x": 5})
|
| 785 |
+
|
| 786 |
+
async def test_cant_call_disabled_tool(self):
|
| 787 |
+
mcp = FastMCP()
|
| 788 |
+
|
| 789 |
+
@mcp.tool(enabled=False)
|
| 790 |
+
def sample_tool(x: int) -> int:
|
| 791 |
+
return x * 2
|
| 792 |
+
|
| 793 |
+
with pytest.raises(Exception, match="Unknown tool"):
|
| 794 |
+
async with Client(mcp) as client:
|
| 795 |
+
await client.call_tool("sample_tool", {"x": 5})
|
| 796 |
+
|
| 797 |
+
|
| 798 |
class TestResource:
|
| 799 |
async def test_text_resource(self):
|
| 800 |
mcp = FastMCP()
|
|
|
|
| 879 |
assert result[0].text == "1" # type: ignore[attr-defined]
|
| 880 |
|
| 881 |
|
| 882 |
+
class TestResourceEnabled:
|
| 883 |
+
async def test_toggle_enabled(self):
|
| 884 |
+
mcp = FastMCP()
|
| 885 |
+
|
| 886 |
+
@mcp.resource("resource://data")
|
| 887 |
+
def sample_resource() -> str:
|
| 888 |
+
return "Hello, world!"
|
| 889 |
+
|
| 890 |
+
assert sample_resource.enabled
|
| 891 |
+
|
| 892 |
+
resource = await mcp.get_resource("resource://data")
|
| 893 |
+
assert resource.enabled
|
| 894 |
+
|
| 895 |
+
resource.disable()
|
| 896 |
+
|
| 897 |
+
assert not resource.enabled
|
| 898 |
+
assert not sample_resource.enabled
|
| 899 |
+
|
| 900 |
+
resource.enable()
|
| 901 |
+
assert resource.enabled
|
| 902 |
+
assert sample_resource.enabled
|
| 903 |
+
|
| 904 |
+
async def test_resource_disabled_in_decorator(self):
|
| 905 |
+
mcp = FastMCP()
|
| 906 |
+
|
| 907 |
+
@mcp.resource("resource://data", enabled=False)
|
| 908 |
+
def sample_resource() -> str:
|
| 909 |
+
return "Hello, world!"
|
| 910 |
+
|
| 911 |
+
async with Client(mcp) as client:
|
| 912 |
+
resources = await client.list_resources()
|
| 913 |
+
assert len(resources) == 0
|
| 914 |
+
|
| 915 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 916 |
+
await client.read_resource(AnyUrl("resource://data"))
|
| 917 |
+
|
| 918 |
+
async def test_resource_toggle_enabled(self):
|
| 919 |
+
mcp = FastMCP()
|
| 920 |
+
|
| 921 |
+
@mcp.resource("resource://data", enabled=False)
|
| 922 |
+
def sample_resource() -> str:
|
| 923 |
+
return "Hello, world!"
|
| 924 |
+
|
| 925 |
+
sample_resource.enable()
|
| 926 |
+
|
| 927 |
+
async with Client(mcp) as client:
|
| 928 |
+
resources = await client.list_resources()
|
| 929 |
+
assert len(resources) == 1
|
| 930 |
+
|
| 931 |
+
async def test_resource_toggle_disabled(self):
|
| 932 |
+
mcp = FastMCP()
|
| 933 |
+
|
| 934 |
+
@mcp.resource("resource://data")
|
| 935 |
+
def sample_resource() -> str:
|
| 936 |
+
return "Hello, world!"
|
| 937 |
+
|
| 938 |
+
sample_resource.disable()
|
| 939 |
+
|
| 940 |
+
async with Client(mcp) as client:
|
| 941 |
+
resources = await client.list_resources()
|
| 942 |
+
assert len(resources) == 0
|
| 943 |
+
|
| 944 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 945 |
+
await client.read_resource(AnyUrl("resource://data"))
|
| 946 |
+
|
| 947 |
+
async def test_get_resource_and_disable(self):
|
| 948 |
+
mcp = FastMCP()
|
| 949 |
+
|
| 950 |
+
@mcp.resource("resource://data")
|
| 951 |
+
def sample_resource() -> str:
|
| 952 |
+
return "Hello, world!"
|
| 953 |
+
|
| 954 |
+
resource = await mcp.get_resource("resource://data")
|
| 955 |
+
assert resource.enabled
|
| 956 |
+
|
| 957 |
+
sample_resource.disable()
|
| 958 |
+
|
| 959 |
+
async with Client(mcp) as client:
|
| 960 |
+
result = await client.list_resources()
|
| 961 |
+
assert len(result) == 0
|
| 962 |
+
|
| 963 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 964 |
+
await client.read_resource(AnyUrl("resource://data"))
|
| 965 |
+
|
| 966 |
+
async def test_cant_read_disabled_resource(self):
|
| 967 |
+
mcp = FastMCP()
|
| 968 |
+
|
| 969 |
+
@mcp.resource("resource://data", enabled=False)
|
| 970 |
+
def sample_resource() -> str:
|
| 971 |
+
return "Hello, world!"
|
| 972 |
+
|
| 973 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 974 |
+
async with Client(mcp) as client:
|
| 975 |
+
await client.read_resource(AnyUrl("resource://data"))
|
| 976 |
+
|
| 977 |
+
|
| 978 |
class TestResourceTemplates:
|
| 979 |
async def test_resource_with_params_not_in_uri(self):
|
| 980 |
"""Test that a resource with function parameters raises an error if the URI
|
|
|
|
| 1226 |
assert result[0].text.startswith("Resource template: test 1") # type: ignore[attr-defined]
|
| 1227 |
|
| 1228 |
|
| 1229 |
+
class TestResourceTemplateEnabled:
|
| 1230 |
+
async def test_toggle_enabled(self):
|
| 1231 |
+
mcp = FastMCP()
|
| 1232 |
+
|
| 1233 |
+
@mcp.resource("resource://{param}")
|
| 1234 |
+
def sample_template(param: str) -> str:
|
| 1235 |
+
return f"Template: {param}"
|
| 1236 |
+
|
| 1237 |
+
assert sample_template.enabled
|
| 1238 |
+
|
| 1239 |
+
template = await mcp.get_resource_template("resource://{param}")
|
| 1240 |
+
assert template.enabled
|
| 1241 |
+
|
| 1242 |
+
template.disable()
|
| 1243 |
+
|
| 1244 |
+
assert not template.enabled
|
| 1245 |
+
assert not sample_template.enabled
|
| 1246 |
+
|
| 1247 |
+
template.enable()
|
| 1248 |
+
assert template.enabled
|
| 1249 |
+
assert sample_template.enabled
|
| 1250 |
+
|
| 1251 |
+
async def test_template_disabled_in_decorator(self):
|
| 1252 |
+
mcp = FastMCP()
|
| 1253 |
+
|
| 1254 |
+
@mcp.resource("resource://{param}", enabled=False)
|
| 1255 |
+
def sample_template(param: str) -> str:
|
| 1256 |
+
return f"Template: {param}"
|
| 1257 |
+
|
| 1258 |
+
async with Client(mcp) as client:
|
| 1259 |
+
templates = await client.list_resource_templates()
|
| 1260 |
+
assert len(templates) == 0
|
| 1261 |
+
|
| 1262 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 1263 |
+
await client.read_resource(AnyUrl("resource://test"))
|
| 1264 |
+
|
| 1265 |
+
async def test_template_toggle_enabled(self):
|
| 1266 |
+
mcp = FastMCP()
|
| 1267 |
+
|
| 1268 |
+
@mcp.resource("resource://{param}", enabled=False)
|
| 1269 |
+
def sample_template(param: str) -> str:
|
| 1270 |
+
return f"Template: {param}"
|
| 1271 |
+
|
| 1272 |
+
sample_template.enable()
|
| 1273 |
+
|
| 1274 |
+
async with Client(mcp) as client:
|
| 1275 |
+
templates = await client.list_resource_templates()
|
| 1276 |
+
assert len(templates) == 1
|
| 1277 |
+
|
| 1278 |
+
async def test_template_toggle_disabled(self):
|
| 1279 |
+
mcp = FastMCP()
|
| 1280 |
+
|
| 1281 |
+
@mcp.resource("resource://{param}")
|
| 1282 |
+
def sample_template(param: str) -> str:
|
| 1283 |
+
return f"Template: {param}"
|
| 1284 |
+
|
| 1285 |
+
sample_template.disable()
|
| 1286 |
+
|
| 1287 |
+
async with Client(mcp) as client:
|
| 1288 |
+
templates = await client.list_resource_templates()
|
| 1289 |
+
assert len(templates) == 0
|
| 1290 |
+
|
| 1291 |
+
async def test_get_template_and_disable(self):
|
| 1292 |
+
mcp = FastMCP()
|
| 1293 |
+
|
| 1294 |
+
@mcp.resource("resource://{param}")
|
| 1295 |
+
def sample_template(param: str) -> str:
|
| 1296 |
+
return f"Template: {param}"
|
| 1297 |
+
|
| 1298 |
+
template = await mcp.get_resource_template("resource://{param}")
|
| 1299 |
+
assert template.enabled
|
| 1300 |
+
|
| 1301 |
+
sample_template.disable()
|
| 1302 |
+
|
| 1303 |
+
async with Client(mcp) as client:
|
| 1304 |
+
result = await client.list_resource_templates()
|
| 1305 |
+
assert len(result) == 0
|
| 1306 |
+
|
| 1307 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 1308 |
+
await client.read_resource(AnyUrl("resource://test"))
|
| 1309 |
+
|
| 1310 |
+
async def test_cant_read_disabled_template(self):
|
| 1311 |
+
mcp = FastMCP()
|
| 1312 |
+
|
| 1313 |
+
@mcp.resource("resource://{param}", enabled=False)
|
| 1314 |
+
def sample_template(param: str) -> str:
|
| 1315 |
+
return f"Template: {param}"
|
| 1316 |
+
|
| 1317 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 1318 |
+
async with Client(mcp) as client:
|
| 1319 |
+
await client.read_resource(AnyUrl("resource://test"))
|
| 1320 |
+
|
| 1321 |
+
|
| 1322 |
class TestPrompts:
|
| 1323 |
"""Test prompt functionality in FastMCP server."""
|
| 1324 |
|
|
|
|
| 1505 |
assert prompt.tags == {"example", "test-tag"}
|
| 1506 |
|
| 1507 |
|
| 1508 |
+
class TestPromptEnabled:
|
| 1509 |
+
async def test_toggle_enabled(self):
|
| 1510 |
+
mcp = FastMCP()
|
| 1511 |
+
|
| 1512 |
+
@mcp.prompt
|
| 1513 |
+
def sample_prompt() -> str:
|
| 1514 |
+
return "Hello, world!"
|
| 1515 |
+
|
| 1516 |
+
assert sample_prompt.enabled
|
| 1517 |
+
|
| 1518 |
+
prompt = await mcp.get_prompt("sample_prompt")
|
| 1519 |
+
assert prompt.enabled
|
| 1520 |
+
|
| 1521 |
+
prompt.disable()
|
| 1522 |
+
|
| 1523 |
+
assert not prompt.enabled
|
| 1524 |
+
assert not sample_prompt.enabled
|
| 1525 |
+
|
| 1526 |
+
prompt.enable()
|
| 1527 |
+
assert prompt.enabled
|
| 1528 |
+
assert sample_prompt.enabled
|
| 1529 |
+
|
| 1530 |
+
async def test_prompt_disabled_in_decorator(self):
|
| 1531 |
+
mcp = FastMCP()
|
| 1532 |
+
|
| 1533 |
+
@mcp.prompt(enabled=False)
|
| 1534 |
+
def sample_prompt() -> str:
|
| 1535 |
+
return "Hello, world!"
|
| 1536 |
+
|
| 1537 |
+
async with Client(mcp) as client:
|
| 1538 |
+
prompts = await client.list_prompts()
|
| 1539 |
+
assert len(prompts) == 0
|
| 1540 |
+
|
| 1541 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 1542 |
+
await client.get_prompt("sample_prompt")
|
| 1543 |
+
|
| 1544 |
+
async def test_prompt_toggle_enabled(self):
|
| 1545 |
+
mcp = FastMCP()
|
| 1546 |
+
|
| 1547 |
+
@mcp.prompt(enabled=False)
|
| 1548 |
+
def sample_prompt() -> str:
|
| 1549 |
+
return "Hello, world!"
|
| 1550 |
+
|
| 1551 |
+
sample_prompt.enable()
|
| 1552 |
+
|
| 1553 |
+
async with Client(mcp) as client:
|
| 1554 |
+
prompts = await client.list_prompts()
|
| 1555 |
+
assert len(prompts) == 1
|
| 1556 |
+
|
| 1557 |
+
async def test_prompt_toggle_disabled(self):
|
| 1558 |
+
mcp = FastMCP()
|
| 1559 |
+
|
| 1560 |
+
@mcp.prompt
|
| 1561 |
+
def sample_prompt() -> str:
|
| 1562 |
+
return "Hello, world!"
|
| 1563 |
+
|
| 1564 |
+
sample_prompt.disable()
|
| 1565 |
+
|
| 1566 |
+
async with Client(mcp) as client:
|
| 1567 |
+
prompts = await client.list_prompts()
|
| 1568 |
+
assert len(prompts) == 0
|
| 1569 |
+
|
| 1570 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 1571 |
+
await client.get_prompt("sample_prompt")
|
| 1572 |
+
|
| 1573 |
+
async def test_get_prompt_and_disable(self):
|
| 1574 |
+
mcp = FastMCP()
|
| 1575 |
+
|
| 1576 |
+
@mcp.prompt
|
| 1577 |
+
def sample_prompt() -> str:
|
| 1578 |
+
return "Hello, world!"
|
| 1579 |
+
|
| 1580 |
+
prompt = await mcp.get_prompt("sample_prompt")
|
| 1581 |
+
assert prompt.enabled
|
| 1582 |
+
|
| 1583 |
+
sample_prompt.disable()
|
| 1584 |
+
|
| 1585 |
+
async with Client(mcp) as client:
|
| 1586 |
+
result = await client.list_prompts()
|
| 1587 |
+
assert len(result) == 0
|
| 1588 |
+
|
| 1589 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 1590 |
+
await client.get_prompt("sample_prompt")
|
| 1591 |
+
|
| 1592 |
+
async def test_cant_get_disabled_prompt(self):
|
| 1593 |
+
mcp = FastMCP()
|
| 1594 |
+
|
| 1595 |
+
@mcp.prompt(enabled=False)
|
| 1596 |
+
def sample_prompt() -> str:
|
| 1597 |
+
return "Hello, world!"
|
| 1598 |
+
|
| 1599 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 1600 |
+
async with Client(mcp) as client:
|
| 1601 |
+
await client.get_prompt("sample_prompt")
|
| 1602 |
+
|
| 1603 |
+
|
| 1604 |
class TestPromptContext:
|
| 1605 |
async def test_prompt_context(self):
|
| 1606 |
mcp = FastMCP()
|
tests/tools/test_tool_transform.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing_extensions import TypedDict
|
|
| 9 |
|
| 10 |
from fastmcp import FastMCP
|
| 11 |
from fastmcp.client.client import Client
|
|
|
|
| 12 |
from fastmcp.tools import Tool, forward, forward_raw
|
| 13 |
from fastmcp.tools.tool import FunctionTool
|
| 14 |
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
|
@@ -51,7 +52,7 @@ async def test_tool_defaults_are_maintained_on_unmapped_args(add_tool):
|
|
| 51 |
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
| 52 |
)
|
| 53 |
result = await new_tool.run(arguments={"new_x": 1})
|
| 54 |
-
assert result[0].text == "11" # type: ignore
|
| 55 |
|
| 56 |
|
| 57 |
async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
|
@@ -59,7 +60,7 @@ async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
|
| 59 |
add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
|
| 60 |
)
|
| 61 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 62 |
-
assert result[0].text == "11" # type: ignore
|
| 63 |
|
| 64 |
|
| 65 |
def test_tool_change_arg_name(add_tool):
|
|
@@ -86,7 +87,7 @@ async def test_tool_drop_arg(add_tool):
|
|
| 86 |
)
|
| 87 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 88 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 89 |
-
assert result[0].text == "11" # type: ignore
|
| 90 |
|
| 91 |
|
| 92 |
async def test_dropped_args_error_if_provided(add_tool):
|
|
@@ -108,7 +109,7 @@ async def test_hidden_arg_with_constant_default(add_tool):
|
|
| 108 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 109 |
# Should pass old_x=5 and old_y=20 to parent
|
| 110 |
result = await new_tool.run(arguments={"old_x": 5})
|
| 111 |
-
assert result[0].text == "25" # type: ignore
|
| 112 |
|
| 113 |
|
| 114 |
async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
|
@@ -120,7 +121,7 @@ async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
|
| 120 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 121 |
# Should pass old_x=3 and let parent use its default old_y=10
|
| 122 |
result = await new_tool.run(arguments={"old_x": 3})
|
| 123 |
-
assert result[0].text == "13" # type: ignore
|
| 124 |
|
| 125 |
|
| 126 |
async def test_mixed_hidden_args_with_custom_function(add_tool):
|
|
@@ -145,7 +146,7 @@ async def test_mixed_hidden_args_with_custom_function(add_tool):
|
|
| 145 |
assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
|
| 146 |
# Should pass visible_x=7 as old_x=7 and old_y=25 to parent
|
| 147 |
result = await new_tool.run(arguments={"visible_x": 7})
|
| 148 |
-
assert result[0].text == "32" # type: ignore
|
| 149 |
|
| 150 |
|
| 151 |
async def test_hide_required_param_without_default_raises_error():
|
|
@@ -183,7 +184,7 @@ async def test_hide_required_param_with_user_default_works():
|
|
| 183 |
assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
|
| 184 |
# Should pass required_param=5 and optional_param=20 to parent
|
| 185 |
result = await new_tool.run(arguments={"optional_param": 20})
|
| 186 |
-
assert result[0].text == "25" # type: ignore
|
| 187 |
|
| 188 |
|
| 189 |
async def test_forward_with_argument_mapping(add_tool):
|
|
@@ -202,7 +203,7 @@ async def test_forward_with_argument_mapping(add_tool):
|
|
| 202 |
)
|
| 203 |
|
| 204 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 205 |
-
assert result[0].text == "5" # type: ignore
|
| 206 |
|
| 207 |
|
| 208 |
async def test_forward_with_incorrect_args_raises_error(add_tool):
|
|
@@ -242,7 +243,7 @@ async def test_forward_raw_without_argument_mapping(add_tool):
|
|
| 242 |
)
|
| 243 |
|
| 244 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 245 |
-
assert result[0].text == "5" # type: ignore
|
| 246 |
|
| 247 |
|
| 248 |
async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
|
@@ -252,7 +253,7 @@ async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
|
| 252 |
|
| 253 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 254 |
result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
|
| 255 |
-
assert result[0].text == "6" # type: ignore
|
| 256 |
assert new_tool.parameters["required"] == IsList(
|
| 257 |
"extra", "old_x", check_order=False
|
| 258 |
)
|
|
@@ -269,7 +270,7 @@ async def test_fn_with_kwargs_passes_through_original_args(add_tool):
|
|
| 269 |
|
| 270 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 271 |
result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
|
| 272 |
-
assert result[0].text == "5" # type: ignore
|
| 273 |
|
| 274 |
|
| 275 |
async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
|
@@ -287,7 +288,7 @@ async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
|
| 287 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 288 |
)
|
| 289 |
result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
|
| 290 |
-
assert result[0].text == "5" # type: ignore
|
| 291 |
|
| 292 |
|
| 293 |
async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
|
@@ -307,7 +308,7 @@ async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
|
| 307 |
result = await new_tool.run(
|
| 308 |
arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
|
| 309 |
)
|
| 310 |
-
assert result[0].text == "10" # type: ignore
|
| 311 |
|
| 312 |
|
| 313 |
async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
|
@@ -325,7 +326,7 @@ async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
|
| 325 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 326 |
) # only map 'a'
|
| 327 |
result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
|
| 328 |
-
assert result[0].text == "6" # type: ignore
|
| 329 |
|
| 330 |
|
| 331 |
async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
|
|
@@ -468,7 +469,7 @@ async def test_tool_transform_chaining(add_tool):
|
|
| 468 |
tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
|
| 469 |
|
| 470 |
result = await tool2.run(arguments={"final_x": 5})
|
| 471 |
-
assert result[0].text == "15" # type: ignore
|
| 472 |
|
| 473 |
# Transform tool1 with custom function that handles all parameters
|
| 474 |
async def custom(final_x: int, **kwargs) -> str:
|
|
@@ -479,7 +480,7 @@ async def test_tool_transform_chaining(add_tool):
|
|
| 479 |
tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
|
| 480 |
)
|
| 481 |
result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
|
| 482 |
-
assert result[0].text == "custom 8" # type: ignore
|
| 483 |
|
| 484 |
|
| 485 |
class MyModel(BaseModel):
|
|
@@ -634,7 +635,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
|
|
| 634 |
# Test it works at runtime
|
| 635 |
result = await tool.run(arguments={"y": "test"})
|
| 636 |
# Should use ArgTransform default of 42
|
| 637 |
-
assert "42: test" in result[0].text # type: ignore
|
| 638 |
|
| 639 |
|
| 640 |
def test_arg_transform_combined_attributes():
|
|
@@ -691,8 +692,8 @@ async def test_arg_transform_type_precedence_runtime():
|
|
| 691 |
|
| 692 |
# Test it works with string input
|
| 693 |
result = await tool.run(arguments={"x": "5", "y": 3})
|
| 694 |
-
assert "String input '5'" in result[0].text # type: ignore
|
| 695 |
-
assert "result: 8" in result[0].text # type: ignore
|
| 696 |
|
| 697 |
|
| 698 |
class TestProxy:
|
|
@@ -727,7 +728,7 @@ class TestProxy:
|
|
| 727 |
async with Client(proxy_server) as client:
|
| 728 |
# The tool should be registered with its transformed name
|
| 729 |
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
| 730 |
-
assert result[0].text == "3" # type: ignore
|
| 731 |
|
| 732 |
|
| 733 |
async def test_arg_transform_default_factory():
|
|
@@ -750,7 +751,7 @@ async def test_arg_transform_default_factory():
|
|
| 750 |
|
| 751 |
# Should work without providing timestamp (gets value from factory)
|
| 752 |
result = await new_tool.run(arguments={"x": 42})
|
| 753 |
-
assert result[0].text == "42_12345.0" # type: ignore
|
| 754 |
|
| 755 |
|
| 756 |
async def test_arg_transform_default_factory_called_each_time():
|
|
@@ -778,11 +779,11 @@ async def test_arg_transform_default_factory_called_each_time():
|
|
| 778 |
|
| 779 |
# First call
|
| 780 |
result1 = await new_tool.run(arguments={"x": 1})
|
| 781 |
-
assert result1[0].text == "1_1" # type: ignore
|
| 782 |
|
| 783 |
# Second call should get a different value
|
| 784 |
result2 = await new_tool.run(arguments={"x": 2})
|
| 785 |
-
assert result2[0].text == "2_2" # type: ignore
|
| 786 |
|
| 787 |
|
| 788 |
async def test_arg_transform_hidden_with_default_factory():
|
|
@@ -807,7 +808,7 @@ async def test_arg_transform_hidden_with_default_factory():
|
|
| 807 |
|
| 808 |
# Should pass hidden request_id with factory value
|
| 809 |
result = await new_tool.run(arguments={"x": 42})
|
| 810 |
-
assert result[0].text == "42_req_123" # type: ignore
|
| 811 |
|
| 812 |
|
| 813 |
async def test_arg_transform_default_and_factory_raises_error():
|
|
@@ -942,3 +943,47 @@ async def test_arg_transform_hide_and_required_raises_error():
|
|
| 942 |
ValueError, match="Cannot specify both 'hide=True' and 'required=True'"
|
| 943 |
):
|
| 944 |
ArgTransform(hide=True, required=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from fastmcp import FastMCP
|
| 11 |
from fastmcp.client.client import Client
|
| 12 |
+
from fastmcp.exceptions import ToolError
|
| 13 |
from fastmcp.tools import Tool, forward, forward_raw
|
| 14 |
from fastmcp.tools.tool import FunctionTool
|
| 15 |
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
|
|
|
| 52 |
add_tool, transform_args={"old_x": ArgTransform(name="new_x")}
|
| 53 |
)
|
| 54 |
result = await new_tool.run(arguments={"new_x": 1})
|
| 55 |
+
assert result[0].text == "11" # type: ignore[attr-defined]
|
| 56 |
|
| 57 |
|
| 58 |
async def test_tool_defaults_are_maintained_on_mapped_args(add_tool):
|
|
|
|
| 60 |
add_tool, transform_args={"old_y": ArgTransform(name="new_y")}
|
| 61 |
)
|
| 62 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 63 |
+
assert result[0].text == "11" # type: ignore[attr-defined]
|
| 64 |
|
| 65 |
|
| 66 |
def test_tool_change_arg_name(add_tool):
|
|
|
|
| 87 |
)
|
| 88 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 89 |
result = await new_tool.run(arguments={"old_x": 1})
|
| 90 |
+
assert result[0].text == "11" # type: ignore[attr-defined]
|
| 91 |
|
| 92 |
|
| 93 |
async def test_dropped_args_error_if_provided(add_tool):
|
|
|
|
| 109 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 110 |
# Should pass old_x=5 and old_y=20 to parent
|
| 111 |
result = await new_tool.run(arguments={"old_x": 5})
|
| 112 |
+
assert result[0].text == "25" # type: ignore[attr-defined]
|
| 113 |
|
| 114 |
|
| 115 |
async def test_hidden_arg_without_default_uses_parent_default(add_tool):
|
|
|
|
| 121 |
assert sorted(new_tool.parameters["properties"]) == ["old_x"]
|
| 122 |
# Should pass old_x=3 and let parent use its default old_y=10
|
| 123 |
result = await new_tool.run(arguments={"old_x": 3})
|
| 124 |
+
assert result[0].text == "13" # type: ignore[attr-defined]
|
| 125 |
|
| 126 |
|
| 127 |
async def test_mixed_hidden_args_with_custom_function(add_tool):
|
|
|
|
| 146 |
assert sorted(new_tool.parameters["properties"]) == ["visible_x"]
|
| 147 |
# Should pass visible_x=7 as old_x=7 and old_y=25 to parent
|
| 148 |
result = await new_tool.run(arguments={"visible_x": 7})
|
| 149 |
+
assert result[0].text == "32" # type: ignore[attr-defined]
|
| 150 |
|
| 151 |
|
| 152 |
async def test_hide_required_param_without_default_raises_error():
|
|
|
|
| 184 |
assert sorted(new_tool.parameters["properties"]) == ["optional_param"]
|
| 185 |
# Should pass required_param=5 and optional_param=20 to parent
|
| 186 |
result = await new_tool.run(arguments={"optional_param": 20})
|
| 187 |
+
assert result[0].text == "25" # type: ignore[attr-defined]
|
| 188 |
|
| 189 |
|
| 190 |
async def test_forward_with_argument_mapping(add_tool):
|
|
|
|
| 203 |
)
|
| 204 |
|
| 205 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 206 |
+
assert result[0].text == "5" # type: ignore[attr-defined]
|
| 207 |
|
| 208 |
|
| 209 |
async def test_forward_with_incorrect_args_raises_error(add_tool):
|
|
|
|
| 243 |
)
|
| 244 |
|
| 245 |
result = await new_tool.run(arguments={"new_x": 2, "new_y": 3})
|
| 246 |
+
assert result[0].text == "5" # type: ignore[attr-defined]
|
| 247 |
|
| 248 |
|
| 249 |
async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
|
|
|
|
| 253 |
|
| 254 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 255 |
result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
|
| 256 |
+
assert result[0].text == "6" # type: ignore[attr-defined]
|
| 257 |
assert new_tool.parameters["required"] == IsList(
|
| 258 |
"extra", "old_x", check_order=False
|
| 259 |
)
|
|
|
|
| 270 |
|
| 271 |
new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
|
| 272 |
result = await new_tool.run(arguments={"new_y": 2, "old_y": 3})
|
| 273 |
+
assert result[0].text == "5" # type: ignore[attr-defined]
|
| 274 |
|
| 275 |
|
| 276 |
async def test_fn_with_kwargs_receives_transformed_arg_names(add_tool):
|
|
|
|
| 288 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 289 |
)
|
| 290 |
result = await new_tool.run(arguments={"new_x": 2, "old_y": 3})
|
| 291 |
+
assert result[0].text == "5" # type: ignore[attr-defined]
|
| 292 |
|
| 293 |
|
| 294 |
async def test_fn_with_kwargs_handles_partial_explicit_args(add_tool):
|
|
|
|
| 308 |
result = await new_tool.run(
|
| 309 |
arguments={"new_x": 3, "old_y": 7, "some_other_param": "test"}
|
| 310 |
)
|
| 311 |
+
assert result[0].text == "10" # type: ignore[attr-defined]
|
| 312 |
|
| 313 |
|
| 314 |
async def test_fn_with_kwargs_mixed_mapped_and_unmapped_args(add_tool):
|
|
|
|
| 326 |
transform_args={"old_x": ArgTransform(name="new_x")},
|
| 327 |
) # only map 'a'
|
| 328 |
result = await new_tool.run(arguments={"new_x": 1, "old_y": 5})
|
| 329 |
+
assert result[0].text == "6" # type: ignore[attr-defined]
|
| 330 |
|
| 331 |
|
| 332 |
async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
|
|
|
|
| 469 |
tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
|
| 470 |
|
| 471 |
result = await tool2.run(arguments={"final_x": 5})
|
| 472 |
+
assert result[0].text == "15" # type: ignore[attr-defined]
|
| 473 |
|
| 474 |
# Transform tool1 with custom function that handles all parameters
|
| 475 |
async def custom(final_x: int, **kwargs) -> str:
|
|
|
|
| 480 |
tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
|
| 481 |
)
|
| 482 |
result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
|
| 483 |
+
assert result[0].text == "custom 8" # type: ignore[attr-defined]
|
| 484 |
|
| 485 |
|
| 486 |
class MyModel(BaseModel):
|
|
|
|
| 635 |
# Test it works at runtime
|
| 636 |
result = await tool.run(arguments={"y": "test"})
|
| 637 |
# Should use ArgTransform default of 42
|
| 638 |
+
assert "42: test" in result[0].text # type: ignore[attr-defined]
|
| 639 |
|
| 640 |
|
| 641 |
def test_arg_transform_combined_attributes():
|
|
|
|
| 692 |
|
| 693 |
# Test it works with string input
|
| 694 |
result = await tool.run(arguments={"x": "5", "y": 3})
|
| 695 |
+
assert "String input '5'" in result[0].text # type: ignore[attr-defined]
|
| 696 |
+
assert "result: 8" in result[0].text # type: ignore[attr-defined]
|
| 697 |
|
| 698 |
|
| 699 |
class TestProxy:
|
|
|
|
| 728 |
async with Client(proxy_server) as client:
|
| 729 |
# The tool should be registered with its transformed name
|
| 730 |
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
|
| 731 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 732 |
|
| 733 |
|
| 734 |
async def test_arg_transform_default_factory():
|
|
|
|
| 751 |
|
| 752 |
# Should work without providing timestamp (gets value from factory)
|
| 753 |
result = await new_tool.run(arguments={"x": 42})
|
| 754 |
+
assert result[0].text == "42_12345.0" # type: ignore[attr-defined]
|
| 755 |
|
| 756 |
|
| 757 |
async def test_arg_transform_default_factory_called_each_time():
|
|
|
|
| 779 |
|
| 780 |
# First call
|
| 781 |
result1 = await new_tool.run(arguments={"x": 1})
|
| 782 |
+
assert result1[0].text == "1_1" # type: ignore[attr-defined]
|
| 783 |
|
| 784 |
# Second call should get a different value
|
| 785 |
result2 = await new_tool.run(arguments={"x": 2})
|
| 786 |
+
assert result2[0].text == "2_2" # type: ignore[attr-defined]
|
| 787 |
|
| 788 |
|
| 789 |
async def test_arg_transform_hidden_with_default_factory():
|
|
|
|
| 808 |
|
| 809 |
# Should pass hidden request_id with factory value
|
| 810 |
result = await new_tool.run(arguments={"x": 42})
|
| 811 |
+
assert result[0].text == "42_req_123" # type: ignore[attr-defined]
|
| 812 |
|
| 813 |
|
| 814 |
async def test_arg_transform_default_and_factory_raises_error():
|
|
|
|
| 943 |
ValueError, match="Cannot specify both 'hide=True' and 'required=True'"
|
| 944 |
):
|
| 945 |
ArgTransform(hide=True, required=True)
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
class TestEnableDisable:
|
| 949 |
+
async def test_transform_disabled_tool(self):
|
| 950 |
+
"""
|
| 951 |
+
Tests that a transformed tool can run even if the parent tool is disabled
|
| 952 |
+
"""
|
| 953 |
+
mcp = FastMCP()
|
| 954 |
+
|
| 955 |
+
@mcp.tool(enabled=False)
|
| 956 |
+
def add(x: int, y: int = 10) -> int:
|
| 957 |
+
return x + y
|
| 958 |
+
|
| 959 |
+
new_add = Tool.from_tool(add, name="new_add")
|
| 960 |
+
mcp.add_tool(new_add)
|
| 961 |
+
|
| 962 |
+
assert new_add.enabled
|
| 963 |
+
|
| 964 |
+
async with Client(mcp) as client:
|
| 965 |
+
tools = await client.list_tools()
|
| 966 |
+
assert {tool.name for tool in tools} == {"new_add"}
|
| 967 |
+
|
| 968 |
+
result = await client.call_tool("new_add", {"x": 1, "y": 2})
|
| 969 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
| 970 |
+
|
| 971 |
+
with pytest.raises(ToolError):
|
| 972 |
+
await client.call_tool("add", {"x": 1, "y": 2})
|
| 973 |
+
|
| 974 |
+
async def test_disable_transformed_tool(self):
|
| 975 |
+
mcp = FastMCP()
|
| 976 |
+
|
| 977 |
+
@mcp.tool(enabled=False)
|
| 978 |
+
def add(x: int, y: int = 10) -> int:
|
| 979 |
+
return x + y
|
| 980 |
+
|
| 981 |
+
new_add = Tool.from_tool(add, name="new_add", enabled=False)
|
| 982 |
+
mcp.add_tool(new_add)
|
| 983 |
+
|
| 984 |
+
async with Client(mcp) as client:
|
| 985 |
+
tools = await client.list_tools()
|
| 986 |
+
assert len(tools) == 0
|
| 987 |
+
|
| 988 |
+
with pytest.raises(ToolError):
|
| 989 |
+
await client.call_tool("new_add", {"x": 1, "y": 2})
|