Spaces:
Running
Running
Jeremiah Lowin Claude commited on
Commit ·
a9a1a98
1
Parent(s): e0dcb2d
Add mirrored component support for proxy servers
Browse filesPrevents enable/disable operations on mirrored components (tools, resources, prompts retrieved from proxy servers). Users must create local copies with .copy() and add them to their server to modify them. Local components take precedence over mirrored ones.
Closes #1102
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- docs/docs.json +3 -3
- docs/servers/composition.mdx +1 -1
- docs/servers/proxy.mdx +27 -10
- src/fastmcp/cli/install/claude_code.py +35 -6
- src/fastmcp/server/proxy.py +9 -4
- src/fastmcp/server/server.py +24 -5
- src/fastmcp/utilities/components.py +43 -0
- tests/server/proxy/test_proxy_server.py +158 -0
docs/docs.json
CHANGED
|
@@ -81,13 +81,13 @@
|
|
| 81 |
"icon": "stars",
|
| 82 |
"pages": [
|
| 83 |
"servers/context",
|
|
|
|
|
|
|
| 84 |
"servers/elicitation",
|
| 85 |
"servers/logging",
|
| 86 |
"servers/progress",
|
| 87 |
"servers/sampling",
|
| 88 |
-
"servers/middleware"
|
| 89 |
-
"servers/composition",
|
| 90 |
-
"servers/proxy"
|
| 91 |
]
|
| 92 |
},
|
| 93 |
{
|
|
|
|
| 81 |
"icon": "stars",
|
| 82 |
"pages": [
|
| 83 |
"servers/context",
|
| 84 |
+
"servers/proxy",
|
| 85 |
+
"servers/composition",
|
| 86 |
"servers/elicitation",
|
| 87 |
"servers/logging",
|
| 88 |
"servers/progress",
|
| 89 |
"servers/sampling",
|
| 90 |
+
"servers/middleware"
|
|
|
|
|
|
|
| 91 |
]
|
| 92 |
},
|
| 93 |
{
|
docs/servers/composition.mdx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
title: Server Composition
|
| 3 |
-
sidebarTitle: Composition
|
| 4 |
description: Combine multiple FastMCP servers into a single, larger application using mounting and importing.
|
| 5 |
icon: puzzle-piece
|
| 6 |
---
|
|
|
|
| 1 |
---
|
| 2 |
title: Server Composition
|
| 3 |
+
sidebarTitle: Server Composition
|
| 4 |
description: Combine multiple FastMCP servers into a single, larger application using mounting and importing.
|
| 5 |
icon: puzzle-piece
|
| 6 |
---
|
docs/servers/proxy.mdx
CHANGED
|
@@ -65,6 +65,10 @@ This single setup gives you:
|
|
| 65 |
- Session isolation to prevent context mixing
|
| 66 |
- Full compatibility with all MCP clients
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
## Session Isolation & Concurrency
|
| 69 |
|
| 70 |
<VersionBadge version="2.10.3" />
|
|
@@ -240,23 +244,35 @@ composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
|
|
| 240 |
# - weather://weather/icons/sunny, calendar://calendar/events/today
|
| 241 |
```
|
| 242 |
|
| 243 |
-
##
|
| 244 |
|
| 245 |
-
|
| 246 |
|
| 247 |
-
|
| 248 |
|
| 249 |
-
|
| 250 |
|
| 251 |
```python
|
| 252 |
-
|
|
|
|
| 253 |
|
| 254 |
-
#
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
```
|
| 258 |
|
| 259 |
-
This approach provides session isolation but doesn't include advanced MCP feature forwarding (sampling, elicitation, etc.) unless you configure handlers manually.
|
| 260 |
|
| 261 |
## `FastMCPProxy` Class
|
| 262 |
|
|
@@ -308,4 +324,5 @@ def custom_client_factory():
|
|
| 308 |
return client
|
| 309 |
|
| 310 |
proxy = FastMCPProxy(client_factory=custom_client_factory)
|
| 311 |
-
```
|
|
|
|
|
|
| 65 |
- Session isolation to prevent context mixing
|
| 66 |
- Full compatibility with all MCP clients
|
| 67 |
|
| 68 |
+
You can also pass a FastMCP [client transport](/clients/transports) (or parameter that can be inferred to a transport) to `as_proxy()`. This will automatically create a `ProxyClient` instance for you.
|
| 69 |
+
|
| 70 |
+
Finally, you can pass a regular FastMCP `Client` instance to `as_proxy()`. This will work for many use cases, but may break if advanced MCP features like sampling or elicitation are invoked by the server.
|
| 71 |
+
|
| 72 |
## Session Isolation & Concurrency
|
| 73 |
|
| 74 |
<VersionBadge version="2.10.3" />
|
|
|
|
| 244 |
# - weather://weather/icons/sunny, calendar://calendar/events/today
|
| 245 |
```
|
| 246 |
|
| 247 |
+
## Mirrored Components
|
| 248 |
|
| 249 |
+
When you access tools, resources, or prompts from a proxy server, they are "mirrored" from the remote server. Mirrored components cannot be modified directly directly since they reflect the state of the remote server. For example, you can not simply "disable" a mirrored component.
|
| 250 |
|
| 251 |
+
However, you can create a copy of a mirrored component and store it as a new locally-defined component. Local components always take precedence over mirored ones because the proxy server will check its own registry before it attempts to engage the remote server.
|
| 252 |
|
| 253 |
+
Therefore, to enable or disable a proxy tool, resource, or prompt, you should first create a local copy and add it to your own server. Here's an example of how to do that for a tool:
|
| 254 |
|
| 255 |
```python
|
| 256 |
+
# Create your own server
|
| 257 |
+
my_server = FastMCP("MyServer")
|
| 258 |
|
| 259 |
+
# Get a proxy server
|
| 260 |
+
proxy = FastMCP.as_proxy("backend_server.py")
|
| 261 |
+
|
| 262 |
+
# Add mirrored components to your server
|
| 263 |
+
async with proxy:
|
| 264 |
+
mirrored_tool = await proxy.get_tool("useful_tool")
|
| 265 |
+
|
| 266 |
+
# Create a local copy that you can modify
|
| 267 |
+
local_tool = mirrored_tool.copy()
|
| 268 |
+
|
| 269 |
+
# Add the local copy to your server
|
| 270 |
+
my_server.add_tool(local_tool)
|
| 271 |
+
|
| 272 |
+
# Now you can disable YOUR copy
|
| 273 |
+
local_tool.disable()
|
| 274 |
```
|
| 275 |
|
|
|
|
| 276 |
|
| 277 |
## `FastMCPProxy` Class
|
| 278 |
|
|
|
|
| 324 |
return client
|
| 325 |
|
| 326 |
proxy = FastMCPProxy(client_factory=custom_client_factory)
|
| 327 |
+
```
|
| 328 |
+
|
src/fastmcp/cli/install/claude_code.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Claude Code integration for FastMCP install using Cyclopts."""
|
| 2 |
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import sys
|
| 5 |
from pathlib import Path
|
|
@@ -16,22 +17,50 @@ logger = get_logger(__name__)
|
|
| 16 |
|
| 17 |
|
| 18 |
def find_claude_command() -> str | None:
|
| 19 |
-
"""Find the Claude Code CLI command.
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
try:
|
| 24 |
result = subprocess.run(
|
| 25 |
-
[
|
| 26 |
check=True,
|
| 27 |
capture_output=True,
|
| 28 |
text=True,
|
| 29 |
)
|
| 30 |
if "Claude Code" in result.stdout:
|
| 31 |
-
return
|
| 32 |
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 33 |
pass
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return None
|
| 36 |
|
| 37 |
|
|
|
|
| 1 |
"""Claude Code integration for FastMCP install using Cyclopts."""
|
| 2 |
|
| 3 |
+
import shutil
|
| 4 |
import subprocess
|
| 5 |
import sys
|
| 6 |
from pathlib import Path
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def find_claude_command() -> str | None:
|
| 20 |
+
"""Find the Claude Code CLI command.
|
| 21 |
+
|
| 22 |
+
Checks common installation locations since 'claude' is often a shell alias
|
| 23 |
+
that doesn't work with subprocess calls.
|
| 24 |
+
"""
|
| 25 |
+
# First try shutil.which() in case it's a real executable in PATH
|
| 26 |
+
claude_in_path = shutil.which("claude")
|
| 27 |
+
if claude_in_path:
|
| 28 |
try:
|
| 29 |
result = subprocess.run(
|
| 30 |
+
[claude_in_path, "--version"],
|
| 31 |
check=True,
|
| 32 |
capture_output=True,
|
| 33 |
text=True,
|
| 34 |
)
|
| 35 |
if "Claude Code" in result.stdout:
|
| 36 |
+
return claude_in_path
|
| 37 |
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 38 |
pass
|
| 39 |
|
| 40 |
+
# Check common installation locations (aliases don't work with subprocess)
|
| 41 |
+
potential_paths = [
|
| 42 |
+
# Default Claude Code installation location (after migration)
|
| 43 |
+
Path.home() / ".claude" / "local" / "claude",
|
| 44 |
+
# npm global installation on macOS/Linux (default)
|
| 45 |
+
Path("/usr/local/bin/claude"),
|
| 46 |
+
# npm global installation with custom prefix
|
| 47 |
+
Path.home() / ".npm-global" / "bin" / "claude",
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
for path in potential_paths:
|
| 51 |
+
if path.exists():
|
| 52 |
+
try:
|
| 53 |
+
result = subprocess.run(
|
| 54 |
+
[str(path), "--version"],
|
| 55 |
+
check=True,
|
| 56 |
+
capture_output=True,
|
| 57 |
+
text=True,
|
| 58 |
+
)
|
| 59 |
+
if "Claude Code" in result.stdout:
|
| 60 |
+
return str(path)
|
| 61 |
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
return None
|
| 65 |
|
| 66 |
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -36,6 +36,7 @@ from fastmcp.server.dependencies import get_context
|
|
| 36 |
from fastmcp.server.server import FastMCP
|
| 37 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 38 |
from fastmcp.tools.tool_manager import ToolManager
|
|
|
|
| 39 |
from fastmcp.utilities.logging import get_logger
|
| 40 |
|
| 41 |
if TYPE_CHECKING:
|
|
@@ -226,7 +227,7 @@ class ProxyPromptManager(PromptManager):
|
|
| 226 |
return result
|
| 227 |
|
| 228 |
|
| 229 |
-
class ProxyTool(Tool):
|
| 230 |
"""
|
| 231 |
A Tool that represents and executes a tool on a remote server.
|
| 232 |
"""
|
|
@@ -245,6 +246,7 @@ class ProxyTool(Tool):
|
|
| 245 |
parameters=mcp_tool.inputSchema,
|
| 246 |
annotations=mcp_tool.annotations,
|
| 247 |
output_schema=mcp_tool.outputSchema,
|
|
|
|
| 248 |
)
|
| 249 |
|
| 250 |
async def run(
|
|
@@ -266,7 +268,7 @@ class ProxyTool(Tool):
|
|
| 266 |
)
|
| 267 |
|
| 268 |
|
| 269 |
-
class ProxyResource(Resource):
|
| 270 |
"""
|
| 271 |
A Resource that represents and reads a resource from a remote server.
|
| 272 |
"""
|
|
@@ -298,6 +300,7 @@ class ProxyResource(Resource):
|
|
| 298 |
name=mcp_resource.name,
|
| 299 |
description=mcp_resource.description,
|
| 300 |
mime_type=mcp_resource.mimeType or "text/plain",
|
|
|
|
| 301 |
)
|
| 302 |
|
| 303 |
async def read(self) -> str | bytes:
|
|
@@ -315,7 +318,7 @@ class ProxyResource(Resource):
|
|
| 315 |
raise ResourceError(f"Unsupported content type: {type(result[0])}")
|
| 316 |
|
| 317 |
|
| 318 |
-
class ProxyTemplate(ResourceTemplate):
|
| 319 |
"""
|
| 320 |
A ResourceTemplate that represents and creates resources from a remote server template.
|
| 321 |
"""
|
|
@@ -336,6 +339,7 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 336 |
description=mcp_template.description,
|
| 337 |
mime_type=mcp_template.mimeType or "text/plain",
|
| 338 |
parameters={}, # Remote templates don't have local parameters
|
|
|
|
| 339 |
)
|
| 340 |
|
| 341 |
async def create_resource(
|
|
@@ -371,7 +375,7 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 371 |
)
|
| 372 |
|
| 373 |
|
| 374 |
-
class ProxyPrompt(Prompt):
|
| 375 |
"""
|
| 376 |
A Prompt that represents and renders a prompt from a remote server.
|
| 377 |
"""
|
|
@@ -400,6 +404,7 @@ class ProxyPrompt(Prompt):
|
|
| 400 |
name=mcp_prompt.name,
|
| 401 |
description=mcp_prompt.description,
|
| 402 |
arguments=arguments,
|
|
|
|
| 403 |
)
|
| 404 |
|
| 405 |
async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
|
|
|
|
| 36 |
from fastmcp.server.server import FastMCP
|
| 37 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 38 |
from fastmcp.tools.tool_manager import ToolManager
|
| 39 |
+
from fastmcp.utilities.components import MirroredComponent
|
| 40 |
from fastmcp.utilities.logging import get_logger
|
| 41 |
|
| 42 |
if TYPE_CHECKING:
|
|
|
|
| 227 |
return result
|
| 228 |
|
| 229 |
|
| 230 |
+
class ProxyTool(Tool, MirroredComponent):
|
| 231 |
"""
|
| 232 |
A Tool that represents and executes a tool on a remote server.
|
| 233 |
"""
|
|
|
|
| 246 |
parameters=mcp_tool.inputSchema,
|
| 247 |
annotations=mcp_tool.annotations,
|
| 248 |
output_schema=mcp_tool.outputSchema,
|
| 249 |
+
_mirrored=True,
|
| 250 |
)
|
| 251 |
|
| 252 |
async def run(
|
|
|
|
| 268 |
)
|
| 269 |
|
| 270 |
|
| 271 |
+
class ProxyResource(Resource, MirroredComponent):
|
| 272 |
"""
|
| 273 |
A Resource that represents and reads a resource from a remote server.
|
| 274 |
"""
|
|
|
|
| 300 |
name=mcp_resource.name,
|
| 301 |
description=mcp_resource.description,
|
| 302 |
mime_type=mcp_resource.mimeType or "text/plain",
|
| 303 |
+
_mirrored=True,
|
| 304 |
)
|
| 305 |
|
| 306 |
async def read(self) -> str | bytes:
|
|
|
|
| 318 |
raise ResourceError(f"Unsupported content type: {type(result[0])}")
|
| 319 |
|
| 320 |
|
| 321 |
+
class ProxyTemplate(ResourceTemplate, MirroredComponent):
|
| 322 |
"""
|
| 323 |
A ResourceTemplate that represents and creates resources from a remote server template.
|
| 324 |
"""
|
|
|
|
| 339 |
description=mcp_template.description,
|
| 340 |
mime_type=mcp_template.mimeType or "text/plain",
|
| 341 |
parameters={}, # Remote templates don't have local parameters
|
| 342 |
+
_mirrored=True,
|
| 343 |
)
|
| 344 |
|
| 345 |
async def create_resource(
|
|
|
|
| 375 |
)
|
| 376 |
|
| 377 |
|
| 378 |
+
class ProxyPrompt(Prompt, MirroredComponent):
|
| 379 |
"""
|
| 380 |
A Prompt that represents and renders a prompt from a remote server.
|
| 381 |
"""
|
|
|
|
| 404 |
name=mcp_prompt.name,
|
| 405 |
description=mcp_prompt.description,
|
| 406 |
arguments=arguments,
|
| 407 |
+
_mirrored=True,
|
| 408 |
)
|
| 409 |
|
| 410 |
async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
|
src/fastmcp/server/server.py
CHANGED
|
@@ -759,7 +759,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 759 |
)
|
| 760 |
return await self._apply_middleware(mw_context, _handler)
|
| 761 |
|
| 762 |
-
def add_tool(self, tool: Tool) ->
|
| 763 |
"""Add a tool to the server.
|
| 764 |
|
| 765 |
The tool function can optionally request a Context object by adding a parameter
|
|
@@ -767,6 +767,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 767 |
|
| 768 |
Args:
|
| 769 |
tool: The Tool instance to register
|
|
|
|
|
|
|
|
|
|
| 770 |
"""
|
| 771 |
self._tool_manager.add_tool(tool)
|
| 772 |
self._cache.clear()
|
|
@@ -780,6 +783,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 780 |
except RuntimeError:
|
| 781 |
pass # No context available
|
| 782 |
|
|
|
|
|
|
|
| 783 |
def remove_tool(self, name: str) -> None:
|
| 784 |
"""Remove a tool from the server.
|
| 785 |
|
|
@@ -958,13 +963,15 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 958 |
enabled=enabled,
|
| 959 |
)
|
| 960 |
|
| 961 |
-
def add_resource(self, resource: Resource) ->
|
| 962 |
"""Add a resource to the server.
|
| 963 |
|
| 964 |
Args:
|
| 965 |
resource: A Resource instance to add
|
| 966 |
-
"""
|
| 967 |
|
|
|
|
|
|
|
|
|
|
| 968 |
self._resource_manager.add_resource(resource)
|
| 969 |
self._cache.clear()
|
| 970 |
|
|
@@ -977,11 +984,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 977 |
except RuntimeError:
|
| 978 |
pass # No context available
|
| 979 |
|
| 980 |
-
|
|
|
|
|
|
|
| 981 |
"""Add a resource template to the server.
|
| 982 |
|
| 983 |
Args:
|
| 984 |
template: A ResourceTemplate instance to add
|
|
|
|
|
|
|
|
|
|
| 985 |
"""
|
| 986 |
self._resource_manager.add_template(template)
|
| 987 |
|
|
@@ -994,6 +1006,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 994 |
except RuntimeError:
|
| 995 |
pass # No context available
|
| 996 |
|
|
|
|
|
|
|
| 997 |
def add_resource_fn(
|
| 998 |
self,
|
| 999 |
fn: AnyFunction,
|
|
@@ -1159,11 +1173,14 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1159 |
|
| 1160 |
return decorator
|
| 1161 |
|
| 1162 |
-
def add_prompt(self, prompt: Prompt) ->
|
| 1163 |
"""Add a prompt to the server.
|
| 1164 |
|
| 1165 |
Args:
|
| 1166 |
prompt: A Prompt instance to add
|
|
|
|
|
|
|
|
|
|
| 1167 |
"""
|
| 1168 |
self._prompt_manager.add_prompt(prompt)
|
| 1169 |
self._cache.clear()
|
|
@@ -1177,6 +1194,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1177 |
except RuntimeError:
|
| 1178 |
pass # No context available
|
| 1179 |
|
|
|
|
|
|
|
| 1180 |
@overload
|
| 1181 |
def prompt(
|
| 1182 |
self,
|
|
|
|
| 759 |
)
|
| 760 |
return await self._apply_middleware(mw_context, _handler)
|
| 761 |
|
| 762 |
+
def add_tool(self, tool: Tool) -> Tool:
|
| 763 |
"""Add a tool to the server.
|
| 764 |
|
| 765 |
The tool function can optionally request a Context object by adding a parameter
|
|
|
|
| 767 |
|
| 768 |
Args:
|
| 769 |
tool: The Tool instance to register
|
| 770 |
+
|
| 771 |
+
Returns:
|
| 772 |
+
The tool instance that was added to the server.
|
| 773 |
"""
|
| 774 |
self._tool_manager.add_tool(tool)
|
| 775 |
self._cache.clear()
|
|
|
|
| 783 |
except RuntimeError:
|
| 784 |
pass # No context available
|
| 785 |
|
| 786 |
+
return tool
|
| 787 |
+
|
| 788 |
def remove_tool(self, name: str) -> None:
|
| 789 |
"""Remove a tool from the server.
|
| 790 |
|
|
|
|
| 963 |
enabled=enabled,
|
| 964 |
)
|
| 965 |
|
| 966 |
+
def add_resource(self, resource: Resource) -> Resource:
|
| 967 |
"""Add a resource to the server.
|
| 968 |
|
| 969 |
Args:
|
| 970 |
resource: A Resource instance to add
|
|
|
|
| 971 |
|
| 972 |
+
Returns:
|
| 973 |
+
The resource instance that was added to the server.
|
| 974 |
+
"""
|
| 975 |
self._resource_manager.add_resource(resource)
|
| 976 |
self._cache.clear()
|
| 977 |
|
|
|
|
| 984 |
except RuntimeError:
|
| 985 |
pass # No context available
|
| 986 |
|
| 987 |
+
return resource
|
| 988 |
+
|
| 989 |
+
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
|
| 990 |
"""Add a resource template to the server.
|
| 991 |
|
| 992 |
Args:
|
| 993 |
template: A ResourceTemplate instance to add
|
| 994 |
+
|
| 995 |
+
Returns:
|
| 996 |
+
The template instance that was added to the server.
|
| 997 |
"""
|
| 998 |
self._resource_manager.add_template(template)
|
| 999 |
|
|
|
|
| 1006 |
except RuntimeError:
|
| 1007 |
pass # No context available
|
| 1008 |
|
| 1009 |
+
return template
|
| 1010 |
+
|
| 1011 |
def add_resource_fn(
|
| 1012 |
self,
|
| 1013 |
fn: AnyFunction,
|
|
|
|
| 1173 |
|
| 1174 |
return decorator
|
| 1175 |
|
| 1176 |
+
def add_prompt(self, prompt: Prompt) -> Prompt:
|
| 1177 |
"""Add a prompt to the server.
|
| 1178 |
|
| 1179 |
Args:
|
| 1180 |
prompt: A Prompt instance to add
|
| 1181 |
+
|
| 1182 |
+
Returns:
|
| 1183 |
+
The prompt instance that was added to the server.
|
| 1184 |
"""
|
| 1185 |
self._prompt_manager.add_prompt(prompt)
|
| 1186 |
self._cache.clear()
|
|
|
|
| 1194 |
except RuntimeError:
|
| 1195 |
pass # No context available
|
| 1196 |
|
| 1197 |
+
return prompt
|
| 1198 |
+
|
| 1199 |
@overload
|
| 1200 |
def prompt(
|
| 1201 |
self,
|
src/fastmcp/utilities/components.py
CHANGED
|
@@ -77,3 +77,46 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
| 77 |
def disable(self) -> None:
|
| 78 |
"""Disable the component."""
|
| 79 |
self.enabled = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def disable(self) -> None:
|
| 78 |
"""Disable the component."""
|
| 79 |
self.enabled = False
|
| 80 |
+
|
| 81 |
+
def copy(self) -> Self:
|
| 82 |
+
"""Create a copy of the component."""
|
| 83 |
+
return self.model_copy()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class MirroredComponent(FastMCPComponent):
|
| 87 |
+
"""Base class for components that are mirrored from a remote server.
|
| 88 |
+
|
| 89 |
+
Mirrored components cannot be enabled or disabled directly. Call copy() first
|
| 90 |
+
to create a local version you can modify.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
_mirrored: bool = PrivateAttr(default=False)
|
| 94 |
+
|
| 95 |
+
def __init__(self, *, _mirrored: bool = False, **kwargs: Any) -> None:
|
| 96 |
+
super().__init__(**kwargs)
|
| 97 |
+
self._mirrored = _mirrored
|
| 98 |
+
|
| 99 |
+
def enable(self) -> None:
|
| 100 |
+
"""Enable the component."""
|
| 101 |
+
if self._mirrored:
|
| 102 |
+
raise RuntimeError(
|
| 103 |
+
f"Cannot enable mirrored component '{self.name}'. "
|
| 104 |
+
f"Create a local copy first with {self.name}.copy() and add it to your server."
|
| 105 |
+
)
|
| 106 |
+
super().enable()
|
| 107 |
+
|
| 108 |
+
def disable(self) -> None:
|
| 109 |
+
"""Disable the component."""
|
| 110 |
+
if self._mirrored:
|
| 111 |
+
raise RuntimeError(
|
| 112 |
+
f"Cannot disable mirrored component '{self.name}'. "
|
| 113 |
+
f"Create a local copy first with {self.name}.copy() and add it to your server."
|
| 114 |
+
)
|
| 115 |
+
super().disable()
|
| 116 |
+
|
| 117 |
+
def copy(self) -> Self:
|
| 118 |
+
"""Create a copy of the component that can be modified."""
|
| 119 |
+
# Create a copy and mark it as not mirrored
|
| 120 |
+
copied = self.model_copy()
|
| 121 |
+
copied._mirrored = False
|
| 122 |
+
return copied
|
tests/server/proxy/test_proxy_server.py
CHANGED
|
@@ -516,3 +516,161 @@ async def test_proxy_handles_multiple_concurrent_tasks_correctly(
|
|
| 516 |
assert list(results["tools"]) == Contains(
|
| 517 |
"greet", "add", "error_tool", "tool_without_description"
|
| 518 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
assert list(results["tools"]) == Contains(
|
| 517 |
"greet", "add", "error_tool", "tool_without_description"
|
| 518 |
)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
class TestMirroredComponents:
|
| 522 |
+
"""Test mirrored component functionality - components retrieved from proxy servers."""
|
| 523 |
+
|
| 524 |
+
async def test_mirrored_tool_cannot_be_enabled(self, proxy_server):
|
| 525 |
+
"""Test that mirrored tools cannot be enabled directly."""
|
| 526 |
+
tools = await proxy_server.get_tools()
|
| 527 |
+
mirrored_tool = tools["greet"]
|
| 528 |
+
|
| 529 |
+
# Verify it's mirrored
|
| 530 |
+
assert mirrored_tool._mirrored is True
|
| 531 |
+
|
| 532 |
+
# Should raise error when trying to enable
|
| 533 |
+
with pytest.raises(RuntimeError, match="Cannot enable mirrored component"):
|
| 534 |
+
mirrored_tool.enable()
|
| 535 |
+
|
| 536 |
+
async def test_mirrored_tool_cannot_be_disabled(self, proxy_server):
|
| 537 |
+
"""Test that mirrored tools cannot be disabled directly."""
|
| 538 |
+
tools = await proxy_server.get_tools()
|
| 539 |
+
mirrored_tool = tools["greet"]
|
| 540 |
+
|
| 541 |
+
# Verify it's mirrored
|
| 542 |
+
assert mirrored_tool._mirrored is True
|
| 543 |
+
|
| 544 |
+
# Should raise error when trying to disable
|
| 545 |
+
with pytest.raises(RuntimeError, match="Cannot disable mirrored component"):
|
| 546 |
+
mirrored_tool.disable()
|
| 547 |
+
|
| 548 |
+
async def test_mirrored_resource_cannot_be_enabled(self, proxy_server):
|
| 549 |
+
"""Test that mirrored resources cannot be enabled directly."""
|
| 550 |
+
resources = await proxy_server.get_resources()
|
| 551 |
+
mirrored_resource = resources["resource://wave"]
|
| 552 |
+
|
| 553 |
+
# Verify it's mirrored
|
| 554 |
+
assert mirrored_resource._mirrored is True
|
| 555 |
+
|
| 556 |
+
# Should raise error when trying to enable
|
| 557 |
+
with pytest.raises(RuntimeError, match="Cannot enable mirrored component"):
|
| 558 |
+
mirrored_resource.enable()
|
| 559 |
+
|
| 560 |
+
async def test_mirrored_resource_cannot_be_disabled(self, proxy_server):
|
| 561 |
+
"""Test that mirrored resources cannot be disabled directly."""
|
| 562 |
+
resources = await proxy_server.get_resources()
|
| 563 |
+
mirrored_resource = resources["resource://wave"]
|
| 564 |
+
|
| 565 |
+
# Verify it's mirrored
|
| 566 |
+
assert mirrored_resource._mirrored is True
|
| 567 |
+
|
| 568 |
+
# Should raise error when trying to disable
|
| 569 |
+
with pytest.raises(RuntimeError, match="Cannot disable mirrored component"):
|
| 570 |
+
mirrored_resource.disable()
|
| 571 |
+
|
| 572 |
+
async def test_mirrored_prompt_cannot_be_enabled(self, proxy_server):
|
| 573 |
+
"""Test that mirrored prompts cannot be enabled directly."""
|
| 574 |
+
prompts = await proxy_server.get_prompts()
|
| 575 |
+
mirrored_prompt = prompts["welcome"]
|
| 576 |
+
|
| 577 |
+
# Verify it's mirrored
|
| 578 |
+
assert mirrored_prompt._mirrored is True
|
| 579 |
+
|
| 580 |
+
# Should raise error when trying to enable
|
| 581 |
+
with pytest.raises(RuntimeError, match="Cannot enable mirrored component"):
|
| 582 |
+
mirrored_prompt.enable()
|
| 583 |
+
|
| 584 |
+
async def test_mirrored_prompt_cannot_be_disabled(self, proxy_server):
|
| 585 |
+
"""Test that mirrored prompts cannot be disabled directly."""
|
| 586 |
+
prompts = await proxy_server.get_prompts()
|
| 587 |
+
mirrored_prompt = prompts["welcome"]
|
| 588 |
+
|
| 589 |
+
# Verify it's mirrored
|
| 590 |
+
assert mirrored_prompt._mirrored is True
|
| 591 |
+
|
| 592 |
+
# Should raise error when trying to disable
|
| 593 |
+
with pytest.raises(RuntimeError, match="Cannot disable mirrored component"):
|
| 594 |
+
mirrored_prompt.disable()
|
| 595 |
+
|
| 596 |
+
async def test_copy_creates_non_mirrored_component(self, proxy_server):
|
| 597 |
+
"""Test that copy() creates a non-mirrored component that can be modified."""
|
| 598 |
+
tools = await proxy_server.get_tools()
|
| 599 |
+
mirrored_tool = tools["greet"]
|
| 600 |
+
|
| 601 |
+
# Create a copy
|
| 602 |
+
local_tool = mirrored_tool.copy()
|
| 603 |
+
|
| 604 |
+
# Copy should not be mirrored
|
| 605 |
+
assert local_tool._mirrored is False
|
| 606 |
+
|
| 607 |
+
# Should be able to enable/disable the copy
|
| 608 |
+
local_tool.enable()
|
| 609 |
+
assert local_tool.enabled is True
|
| 610 |
+
|
| 611 |
+
local_tool.disable()
|
| 612 |
+
assert local_tool.enabled is False
|
| 613 |
+
|
| 614 |
+
async def test_local_component_takes_precedence_over_mirrored(self, proxy_server):
|
| 615 |
+
"""Test that local components take precedence over mirrored ones."""
|
| 616 |
+
# Get the mirrored tool
|
| 617 |
+
tools = await proxy_server.get_tools()
|
| 618 |
+
mirrored_tool = tools["greet"]
|
| 619 |
+
|
| 620 |
+
# Create a local copy and add it
|
| 621 |
+
local_tool = mirrored_tool.copy()
|
| 622 |
+
proxy_server.add_tool(local_tool)
|
| 623 |
+
|
| 624 |
+
# Disable the local copy
|
| 625 |
+
local_tool.disable()
|
| 626 |
+
|
| 627 |
+
# The local disabled tool should take precedence
|
| 628 |
+
updated_tools = await proxy_server.get_tools()
|
| 629 |
+
final_tool = updated_tools["greet"]
|
| 630 |
+
|
| 631 |
+
# Should be the local tool (not mirrored) and disabled
|
| 632 |
+
assert final_tool is local_tool
|
| 633 |
+
assert final_tool._mirrored is False
|
| 634 |
+
assert final_tool.enabled is False
|
| 635 |
+
|
| 636 |
+
async def test_error_messages_mention_copy_method(self, proxy_server):
|
| 637 |
+
"""Test that error messages guide users to use copy() method."""
|
| 638 |
+
tools = await proxy_server.get_tools()
|
| 639 |
+
mirrored_tool = tools["greet"]
|
| 640 |
+
|
| 641 |
+
# Check enable error message
|
| 642 |
+
with pytest.raises(RuntimeError) as exc_info:
|
| 643 |
+
mirrored_tool.enable()
|
| 644 |
+
assert "copy()" in str(exc_info.value)
|
| 645 |
+
|
| 646 |
+
# Check disable error message
|
| 647 |
+
with pytest.raises(RuntimeError) as exc_info:
|
| 648 |
+
mirrored_tool.disable()
|
| 649 |
+
assert "copy()" in str(exc_info.value)
|
| 650 |
+
|
| 651 |
+
async def test_client_cannot_call_disabled_proxy_tool(self, proxy_server):
|
| 652 |
+
"""Test that clients cannot call a tool when local copy is disabled."""
|
| 653 |
+
# Get the mirrored tool
|
| 654 |
+
tools = await proxy_server.get_tools()
|
| 655 |
+
mirrored_tool = tools["greet"]
|
| 656 |
+
|
| 657 |
+
# Verify the tool works initially
|
| 658 |
+
async with Client(proxy_server) as client:
|
| 659 |
+
result = await client.call_tool("greet", {"name": "Alice"})
|
| 660 |
+
assert result.data == "Hello, Alice!"
|
| 661 |
+
|
| 662 |
+
# Create a local copy and disable it
|
| 663 |
+
local_tool = mirrored_tool.copy()
|
| 664 |
+
proxy_server.add_tool(local_tool)
|
| 665 |
+
local_tool.disable()
|
| 666 |
+
|
| 667 |
+
# Client should now get "Unknown tool" error
|
| 668 |
+
async with Client(proxy_server) as client:
|
| 669 |
+
with pytest.raises(ToolError, match="Unknown tool"):
|
| 670 |
+
await client.call_tool("greet", {"name": "Alice"})
|
| 671 |
+
|
| 672 |
+
# Tool should not appear in tool list either
|
| 673 |
+
async with Client(proxy_server) as client:
|
| 674 |
+
tools_list = await client.list_tools()
|
| 675 |
+
tool_names = [tool.name for tool in tools_list]
|
| 676 |
+
assert "greet" not in tool_names
|