Spaces:
Running
Running
hopeful0 commited on
Proxy support advanced MCP features (#1022)
Browse files* Proxy support advanced MCP features
* Optimize ProxyClient testing
* Add documentation for ProxyClient
- docs/servers/proxy.mdx +6 -0
- src/fastmcp/server/proxy.py +116 -0
- src/fastmcp/server/server.py +14 -13
- tests/server/proxy/test_proxy_client.py +257 -0
- tests/server/test_proxy.py +3 -3
docs/servers/proxy.mdx
CHANGED
|
@@ -162,6 +162,12 @@ composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
|
|
| 162 |
# - weather://weather/icons/sunny, calendar://calendar/events/today
|
| 163 |
```
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
## `FastMCPProxy` Class
|
| 166 |
|
| 167 |
Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed.
|
|
|
|
| 162 |
# - weather://weather/icons/sunny, calendar://calendar/events/today
|
| 163 |
```
|
| 164 |
|
| 165 |
+
## Forwarding Interactions
|
| 166 |
+
|
| 167 |
+
`ProxyClient` is a subclass of `Client` that implements a set of default handlers to forward advanced interactions between the backend server and the client connected to the proxy. These handlers receive requests or notifications from the backend server and forward them to the client through the related request context, relaying the response back to the backend server if needed. This setup enables the proxy to support advanced MCP features, including roots, sampling, elicitation, logging, and progress.
|
| 168 |
+
|
| 169 |
+
To prevent the forwarding of any interaction, pass `None` to the corresponding handler when creating the `ProxyClient`. Typically, you should use `ProxyClient` to establish a proxy unless there is a specific reason not to. If the `transport` parameter is not an instance of `Client`, `FastMCP.as_proxy()` will automatically instantiate a `ProxyClient`.
|
| 170 |
+
|
| 171 |
## `FastMCPProxy` Class
|
| 172 |
|
| 173 |
Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed.
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from typing import TYPE_CHECKING, Any, cast
|
| 4 |
from urllib.parse import quote
|
| 5 |
|
| 6 |
import mcp.types
|
|
|
|
|
|
|
| 7 |
from mcp.shared.exceptions import McpError
|
| 8 |
from mcp.types import (
|
| 9 |
METHOD_NOT_FOUND,
|
|
@@ -14,6 +17,10 @@ from mcp.types import (
|
|
| 14 |
from pydantic.networks import AnyUrl
|
| 15 |
|
| 16 |
from fastmcp.client import Client
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
|
| 18 |
from fastmcp.prompts import Prompt, PromptMessage
|
| 19 |
from fastmcp.prompts.prompt import PromptArgument
|
|
@@ -21,10 +28,12 @@ from fastmcp.prompts.prompt_manager import PromptManager
|
|
| 21 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 22 |
from fastmcp.resources.resource_manager import ResourceManager
|
| 23 |
from fastmcp.server.context import Context
|
|
|
|
| 24 |
from fastmcp.server.server import FastMCP
|
| 25 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 26 |
from fastmcp.tools.tool_manager import ToolManager
|
| 27 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 28 |
|
| 29 |
if TYPE_CHECKING:
|
| 30 |
from fastmcp.server import Context
|
|
@@ -406,3 +415,110 @@ class FastMCPProxy(FastMCP):
|
|
| 406 |
self._tool_manager = ProxyToolManager(client=self.client)
|
| 407 |
self._resource_manager = ProxyResourceManager(client=self.client)
|
| 408 |
self._prompt_manager = ProxyPromptManager(client=self.client)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from pathlib import Path
|
| 4 |
from typing import TYPE_CHECKING, Any, cast
|
| 5 |
from urllib.parse import quote
|
| 6 |
|
| 7 |
import mcp.types
|
| 8 |
+
from mcp.client.session import ClientSession
|
| 9 |
+
from mcp.shared.context import LifespanContextT, RequestContext
|
| 10 |
from mcp.shared.exceptions import McpError
|
| 11 |
from mcp.types import (
|
| 12 |
METHOD_NOT_FOUND,
|
|
|
|
| 17 |
from pydantic.networks import AnyUrl
|
| 18 |
|
| 19 |
from fastmcp.client import Client
|
| 20 |
+
from fastmcp.client.elicitation import ElicitResult
|
| 21 |
+
from fastmcp.client.logging import LogMessage
|
| 22 |
+
from fastmcp.client.roots import RootsList
|
| 23 |
+
from fastmcp.client.transports import ClientTransportT
|
| 24 |
from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
|
| 25 |
from fastmcp.prompts import Prompt, PromptMessage
|
| 26 |
from fastmcp.prompts.prompt import PromptArgument
|
|
|
|
| 28 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 29 |
from fastmcp.resources.resource_manager import ResourceManager
|
| 30 |
from fastmcp.server.context import Context
|
| 31 |
+
from fastmcp.server.dependencies import get_context
|
| 32 |
from fastmcp.server.server import FastMCP
|
| 33 |
from fastmcp.tools.tool import Tool, ToolResult
|
| 34 |
from fastmcp.tools.tool_manager import ToolManager
|
| 35 |
from fastmcp.utilities.logging import get_logger
|
| 36 |
+
from fastmcp.utilities.mcp_config import MCPConfig
|
| 37 |
|
| 38 |
if TYPE_CHECKING:
|
| 39 |
from fastmcp.server import Context
|
|
|
|
| 415 |
self._tool_manager = ProxyToolManager(client=self.client)
|
| 416 |
self._resource_manager = ProxyResourceManager(client=self.client)
|
| 417 |
self._prompt_manager = ProxyPromptManager(client=self.client)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
async def default_proxy_roots_handler(
|
| 421 |
+
context: RequestContext[ClientSession, LifespanContextT],
|
| 422 |
+
) -> RootsList:
|
| 423 |
+
"""
|
| 424 |
+
A handler that forwards the list roots request from the remote server to the proxy's connected clients and relays the response back to the remote server.
|
| 425 |
+
"""
|
| 426 |
+
ctx = get_context()
|
| 427 |
+
return await ctx.list_roots()
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
class ProxyClient(Client[ClientTransportT]):
|
| 431 |
+
"""
|
| 432 |
+
A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients.
|
| 433 |
+
Supports forwarding roots, sampling, elicitation, logging, and progress.
|
| 434 |
+
"""
|
| 435 |
+
|
| 436 |
+
def __init__(
|
| 437 |
+
self,
|
| 438 |
+
transport: (
|
| 439 |
+
ClientTransportT
|
| 440 |
+
| FastMCP
|
| 441 |
+
| AnyUrl
|
| 442 |
+
| Path
|
| 443 |
+
| MCPConfig
|
| 444 |
+
| dict[str, Any]
|
| 445 |
+
| str
|
| 446 |
+
),
|
| 447 |
+
**kwargs,
|
| 448 |
+
):
|
| 449 |
+
if "roots" not in kwargs:
|
| 450 |
+
kwargs["roots"] = default_proxy_roots_handler
|
| 451 |
+
if "sampling_handler" not in kwargs:
|
| 452 |
+
kwargs["sampling_handler"] = ProxyClient.default_sampling_handler
|
| 453 |
+
if "elicitation_handler" not in kwargs:
|
| 454 |
+
kwargs["elicitation_handler"] = ProxyClient.default_elicitation_handler
|
| 455 |
+
if "log_handler" not in kwargs:
|
| 456 |
+
kwargs["log_handler"] = ProxyClient.default_log_handler
|
| 457 |
+
if "progress_handler" not in kwargs:
|
| 458 |
+
kwargs["progress_handler"] = ProxyClient.default_progress_handler
|
| 459 |
+
super().__init__(transport, **kwargs)
|
| 460 |
+
|
| 461 |
+
@classmethod
|
| 462 |
+
async def default_sampling_handler(
|
| 463 |
+
cls,
|
| 464 |
+
messages: list[mcp.types.SamplingMessage],
|
| 465 |
+
params: mcp.types.CreateMessageRequestParams,
|
| 466 |
+
context: RequestContext[ClientSession, LifespanContextT],
|
| 467 |
+
) -> mcp.types.CreateMessageResult:
|
| 468 |
+
"""
|
| 469 |
+
A handler that forwards the sampling request from the remote server to the proxy's connected clients and relays the response back to the remote server.
|
| 470 |
+
"""
|
| 471 |
+
ctx = get_context()
|
| 472 |
+
content = await ctx.sample(
|
| 473 |
+
[msg for msg in messages],
|
| 474 |
+
system_prompt=params.systemPrompt,
|
| 475 |
+
temperature=params.temperature,
|
| 476 |
+
max_tokens=params.maxTokens,
|
| 477 |
+
model_preferences=params.modelPreferences,
|
| 478 |
+
)
|
| 479 |
+
if isinstance(content, mcp.types.ResourceLink | mcp.types.EmbeddedResource):
|
| 480 |
+
raise RuntimeError("Content is not supported")
|
| 481 |
+
return mcp.types.CreateMessageResult(
|
| 482 |
+
role="assistant",
|
| 483 |
+
model="fastmcp-client",
|
| 484 |
+
content=content,
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
@classmethod
|
| 488 |
+
async def default_elicitation_handler(
|
| 489 |
+
cls,
|
| 490 |
+
message: str,
|
| 491 |
+
response_type: type,
|
| 492 |
+
params: mcp.types.ElicitRequestParams,
|
| 493 |
+
context: RequestContext[ClientSession, LifespanContextT],
|
| 494 |
+
) -> ElicitResult:
|
| 495 |
+
"""
|
| 496 |
+
A handler that forwards the elicitation request from the remote server to the proxy's connected clients and relays the response back to the remote server.
|
| 497 |
+
"""
|
| 498 |
+
ctx = get_context()
|
| 499 |
+
result = await ctx.elicit(message, response_type)
|
| 500 |
+
if result.action == "accept":
|
| 501 |
+
return result.data
|
| 502 |
+
else:
|
| 503 |
+
return ElicitResult(action=result.action)
|
| 504 |
+
|
| 505 |
+
@classmethod
|
| 506 |
+
async def default_log_handler(cls, message: LogMessage) -> None:
|
| 507 |
+
"""
|
| 508 |
+
A handler that forwards the log notification from the remote server to the proxy's connected clients.
|
| 509 |
+
"""
|
| 510 |
+
ctx = get_context()
|
| 511 |
+
await ctx.log(message.data, level=message.level, logger_name=message.logger)
|
| 512 |
+
|
| 513 |
+
@classmethod
|
| 514 |
+
async def default_progress_handler(
|
| 515 |
+
cls,
|
| 516 |
+
progress: float,
|
| 517 |
+
total: float | None,
|
| 518 |
+
message: str | None,
|
| 519 |
+
) -> None:
|
| 520 |
+
"""
|
| 521 |
+
A handler that forwards the progress notification from the remote server to the proxy's connected clients.
|
| 522 |
+
"""
|
| 523 |
+
ctx = get_context()
|
| 524 |
+
await ctx.report_progress(progress, total, message)
|
src/fastmcp/server/server.py
CHANGED
|
@@ -1643,9 +1643,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1643 |
resource_separator: Deprecated. Separator character for resource URIs.
|
| 1644 |
prompt_separator: Deprecated. Separator character for prompt names.
|
| 1645 |
"""
|
| 1646 |
-
from fastmcp import Client
|
| 1647 |
from fastmcp.client.transports import FastMCPTransport
|
| 1648 |
-
from fastmcp.server.proxy import FastMCPProxy
|
| 1649 |
|
| 1650 |
# Deprecated since 2.9.0
|
| 1651 |
# Prior to 2.9.0, the first positional argument was the prefix and the
|
|
@@ -1697,7 +1696,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1697 |
as_proxy = server._has_lifespan
|
| 1698 |
|
| 1699 |
if as_proxy and not isinstance(server, FastMCPProxy):
|
| 1700 |
-
server = FastMCPProxy(
|
| 1701 |
|
| 1702 |
# Delegate mounting to all three managers
|
| 1703 |
mounted_server = MountedServer(
|
|
@@ -1908,14 +1907,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1908 |
@classmethod
|
| 1909 |
def as_proxy(
|
| 1910 |
cls,
|
| 1911 |
-
backend:
|
| 1912 |
-
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
|
| 1916 |
-
|
| 1917 |
-
|
| 1918 |
-
|
|
|
|
|
|
|
| 1919 |
**settings: Any,
|
| 1920 |
) -> FastMCPProxy:
|
| 1921 |
"""Create a FastMCP proxy server for the given backend.
|
|
@@ -1926,12 +1927,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1926 |
`fastmcp.client.Client` constructor.
|
| 1927 |
"""
|
| 1928 |
from fastmcp.client.client import Client
|
| 1929 |
-
from fastmcp.server.proxy import FastMCPProxy
|
| 1930 |
|
| 1931 |
if isinstance(backend, Client):
|
| 1932 |
client = backend
|
| 1933 |
else:
|
| 1934 |
-
client =
|
| 1935 |
|
| 1936 |
return FastMCPProxy(client=client, **settings)
|
| 1937 |
|
|
|
|
| 1643 |
resource_separator: Deprecated. Separator character for resource URIs.
|
| 1644 |
prompt_separator: Deprecated. Separator character for prompt names.
|
| 1645 |
"""
|
|
|
|
| 1646 |
from fastmcp.client.transports import FastMCPTransport
|
| 1647 |
+
from fastmcp.server.proxy import FastMCPProxy, ProxyClient
|
| 1648 |
|
| 1649 |
# Deprecated since 2.9.0
|
| 1650 |
# Prior to 2.9.0, the first positional argument was the prefix and the
|
|
|
|
| 1696 |
as_proxy = server._has_lifespan
|
| 1697 |
|
| 1698 |
if as_proxy and not isinstance(server, FastMCPProxy):
|
| 1699 |
+
server = FastMCPProxy(ProxyClient(transport=FastMCPTransport(server)))
|
| 1700 |
|
| 1701 |
# Delegate mounting to all three managers
|
| 1702 |
mounted_server = MountedServer(
|
|
|
|
| 1907 |
@classmethod
|
| 1908 |
def as_proxy(
|
| 1909 |
cls,
|
| 1910 |
+
backend: (
|
| 1911 |
+
Client[ClientTransportT]
|
| 1912 |
+
| ClientTransport
|
| 1913 |
+
| FastMCP[Any]
|
| 1914 |
+
| AnyUrl
|
| 1915 |
+
| Path
|
| 1916 |
+
| MCPConfig
|
| 1917 |
+
| dict[str, Any]
|
| 1918 |
+
| str
|
| 1919 |
+
),
|
| 1920 |
**settings: Any,
|
| 1921 |
) -> FastMCPProxy:
|
| 1922 |
"""Create a FastMCP proxy server for the given backend.
|
|
|
|
| 1927 |
`fastmcp.client.Client` constructor.
|
| 1928 |
"""
|
| 1929 |
from fastmcp.client.client import Client
|
| 1930 |
+
from fastmcp.server.proxy import FastMCPProxy, ProxyClient
|
| 1931 |
|
| 1932 |
if isinstance(backend, Client):
|
| 1933 |
client = backend
|
| 1934 |
else:
|
| 1935 |
+
client = ProxyClient(backend)
|
| 1936 |
|
| 1937 |
return FastMCPProxy(client=client, **settings)
|
| 1938 |
|
tests/server/proxy/test_proxy_client.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import cast
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from mcp.types import LoggingLevel, ModelHint, ModelPreferences, TextContent
|
| 6 |
+
|
| 7 |
+
from fastmcp import Client, Context, FastMCP
|
| 8 |
+
from fastmcp.client.elicitation import ElicitRequestParams, ElicitResult
|
| 9 |
+
from fastmcp.client.logging import LogMessage
|
| 10 |
+
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
|
| 11 |
+
from fastmcp.exceptions import ToolError
|
| 12 |
+
from fastmcp.server.proxy import ProxyClient
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def fastmcp_server():
|
| 17 |
+
mcp = FastMCP("TestServer")
|
| 18 |
+
|
| 19 |
+
@mcp.tool
|
| 20 |
+
async def list_roots(context: Context) -> list[str]:
|
| 21 |
+
roots = await context.list_roots()
|
| 22 |
+
return [str(r.uri) for r in roots]
|
| 23 |
+
|
| 24 |
+
@mcp.tool
|
| 25 |
+
async def sampling(
|
| 26 |
+
context: Context,
|
| 27 |
+
) -> str:
|
| 28 |
+
result = await context.sample(
|
| 29 |
+
"Hello, world!",
|
| 30 |
+
system_prompt="You love FastMCP",
|
| 31 |
+
temperature=0.5,
|
| 32 |
+
max_tokens=100,
|
| 33 |
+
model_preferences="gpt-4o",
|
| 34 |
+
)
|
| 35 |
+
return cast(TextContent, result).text
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Person:
|
| 39 |
+
name: str
|
| 40 |
+
|
| 41 |
+
@mcp.tool
|
| 42 |
+
async def elicit(context: Context) -> str:
|
| 43 |
+
result = await context.elicit(
|
| 44 |
+
message="What is your name?",
|
| 45 |
+
response_type=Person,
|
| 46 |
+
)
|
| 47 |
+
if result.action == "accept":
|
| 48 |
+
return f"Hello, {result.data.name}!"
|
| 49 |
+
else:
|
| 50 |
+
return "No name provided."
|
| 51 |
+
|
| 52 |
+
@mcp.tool
|
| 53 |
+
async def log(
|
| 54 |
+
message: str, level: LoggingLevel, logger: str, context: Context
|
| 55 |
+
) -> None:
|
| 56 |
+
await context.log(message=message, level=level, logger_name=logger)
|
| 57 |
+
|
| 58 |
+
@mcp.tool
|
| 59 |
+
async def report_progress(context: Context) -> int:
|
| 60 |
+
for i in range(3):
|
| 61 |
+
await context.report_progress(
|
| 62 |
+
progress=i + 1,
|
| 63 |
+
total=3,
|
| 64 |
+
message=f"{(i + 1) / 3 * 100:.2f}% complete",
|
| 65 |
+
)
|
| 66 |
+
return 100
|
| 67 |
+
|
| 68 |
+
return mcp
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture
|
| 72 |
+
async def proxy_server(fastmcp_server: FastMCP):
|
| 73 |
+
"""
|
| 74 |
+
A proxy server that forwards interactions with the proxy client to the given fastmcp server.
|
| 75 |
+
"""
|
| 76 |
+
return FastMCP.as_proxy(ProxyClient(fastmcp_server))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class TestProxyClient:
|
| 80 |
+
async def test_forward_error_response(self, proxy_server: FastMCP):
|
| 81 |
+
"""
|
| 82 |
+
Test that the proxy client correctly forwards an error response.
|
| 83 |
+
"""
|
| 84 |
+
async with Client(proxy_server) as client:
|
| 85 |
+
with pytest.raises(ToolError, match="Elicitation not supported"):
|
| 86 |
+
await client.call_tool("elicit", {})
|
| 87 |
+
|
| 88 |
+
async def test_forward_list_roots_request(self, proxy_server: FastMCP):
|
| 89 |
+
"""
|
| 90 |
+
Test that the proxy client correctly forwards the `list_roots` request.
|
| 91 |
+
"""
|
| 92 |
+
roots_handler_called = False
|
| 93 |
+
|
| 94 |
+
async def roots_handler(ctx: RequestContext):
|
| 95 |
+
nonlocal roots_handler_called
|
| 96 |
+
roots_handler_called = True
|
| 97 |
+
return []
|
| 98 |
+
|
| 99 |
+
async with Client(proxy_server, roots=roots_handler) as client:
|
| 100 |
+
await client.call_tool("list_roots", {})
|
| 101 |
+
|
| 102 |
+
assert roots_handler_called
|
| 103 |
+
|
| 104 |
+
async def test_forward_list_roots_response(self, proxy_server: FastMCP):
|
| 105 |
+
"""
|
| 106 |
+
Test that the proxy client correctly forwards the `list_roots` response.
|
| 107 |
+
"""
|
| 108 |
+
async with Client(proxy_server, roots=["file://x/y/z"]) as client:
|
| 109 |
+
result = await client.call_tool("list_roots", {})
|
| 110 |
+
assert result.data == ["file://x/y/z"]
|
| 111 |
+
|
| 112 |
+
async def test_forward_sampling_request(self, proxy_server: FastMCP):
|
| 113 |
+
"""
|
| 114 |
+
Test that the proxy client correctly forwards the `sampling` request.
|
| 115 |
+
"""
|
| 116 |
+
sampling_handler_called = False
|
| 117 |
+
|
| 118 |
+
def sampling_handler(
|
| 119 |
+
messages: list[SamplingMessage],
|
| 120 |
+
params: SamplingParams,
|
| 121 |
+
ctx: RequestContext,
|
| 122 |
+
) -> str:
|
| 123 |
+
nonlocal sampling_handler_called
|
| 124 |
+
sampling_handler_called = True
|
| 125 |
+
assert messages == [
|
| 126 |
+
SamplingMessage(
|
| 127 |
+
role="user",
|
| 128 |
+
content=TextContent(type="text", text="Hello, world!"),
|
| 129 |
+
)
|
| 130 |
+
]
|
| 131 |
+
assert params.systemPrompt == "You love FastMCP"
|
| 132 |
+
assert params.temperature == 0.5
|
| 133 |
+
assert params.maxTokens == 100
|
| 134 |
+
assert params.modelPreferences == ModelPreferences(
|
| 135 |
+
hints=[ModelHint(name="gpt-4o")]
|
| 136 |
+
)
|
| 137 |
+
return ""
|
| 138 |
+
|
| 139 |
+
async with Client(proxy_server, sampling_handler=sampling_handler) as client:
|
| 140 |
+
await client.call_tool("sampling", {})
|
| 141 |
+
|
| 142 |
+
assert sampling_handler_called
|
| 143 |
+
|
| 144 |
+
async def test_forward_sampling_response(self, proxy_server: FastMCP):
|
| 145 |
+
"""
|
| 146 |
+
Test that the proxy client correctly forwards the `sampling` response.
|
| 147 |
+
"""
|
| 148 |
+
async with Client(
|
| 149 |
+
proxy_server, sampling_handler=lambda *args: "I love FastMCP"
|
| 150 |
+
) as client:
|
| 151 |
+
result = await client.call_tool("sampling", {})
|
| 152 |
+
assert result.data == "I love FastMCP"
|
| 153 |
+
|
| 154 |
+
async def test_elicit_request(self, proxy_server: FastMCP):
|
| 155 |
+
"""
|
| 156 |
+
Test that the proxy client correctly forwards the `elicit` request.
|
| 157 |
+
"""
|
| 158 |
+
elicitation_handler_called = False
|
| 159 |
+
|
| 160 |
+
async def elicitation_handler(
|
| 161 |
+
message, response_type, params: ElicitRequestParams, ctx
|
| 162 |
+
):
|
| 163 |
+
nonlocal elicitation_handler_called
|
| 164 |
+
elicitation_handler_called = True
|
| 165 |
+
assert message == "What is your name?"
|
| 166 |
+
assert "Person" in str(response_type)
|
| 167 |
+
assert params.requestedSchema == {
|
| 168 |
+
"title": "Person",
|
| 169 |
+
"type": "object",
|
| 170 |
+
"properties": {"name": {"title": "Name", "type": "string"}},
|
| 171 |
+
"required": ["name"],
|
| 172 |
+
}
|
| 173 |
+
return ElicitResult(action="accept", content=response_type(name="Alice"))
|
| 174 |
+
|
| 175 |
+
async with Client(
|
| 176 |
+
proxy_server, elicitation_handler=elicitation_handler
|
| 177 |
+
) as client:
|
| 178 |
+
await client.call_tool("elicit", {})
|
| 179 |
+
|
| 180 |
+
assert elicitation_handler_called
|
| 181 |
+
|
| 182 |
+
async def test_elicit_accept_response(self, proxy_server: FastMCP):
|
| 183 |
+
"""
|
| 184 |
+
Test that the proxy client correctly forwards the `elicit` accept response.
|
| 185 |
+
"""
|
| 186 |
+
|
| 187 |
+
async def elicitation_handler(
|
| 188 |
+
message, response_type, params: ElicitRequestParams, ctx
|
| 189 |
+
):
|
| 190 |
+
return ElicitResult(action="accept", content=response_type(name="Alice"))
|
| 191 |
+
|
| 192 |
+
async with Client(
|
| 193 |
+
proxy_server,
|
| 194 |
+
elicitation_handler=elicitation_handler,
|
| 195 |
+
) as client:
|
| 196 |
+
result = await client.call_tool("elicit", {})
|
| 197 |
+
assert result.data == "Hello, Alice!"
|
| 198 |
+
|
| 199 |
+
async def test_elicit_decline_response(self, proxy_server: FastMCP):
|
| 200 |
+
"""
|
| 201 |
+
Test that the proxy client correctly forwards the `elicit` decline response.
|
| 202 |
+
"""
|
| 203 |
+
|
| 204 |
+
async def elicitation_handler(
|
| 205 |
+
message, response_type, params: ElicitRequestParams, ctx
|
| 206 |
+
):
|
| 207 |
+
return ElicitResult(action="decline")
|
| 208 |
+
|
| 209 |
+
async with Client(
|
| 210 |
+
proxy_server, elicitation_handler=elicitation_handler
|
| 211 |
+
) as client:
|
| 212 |
+
result = await client.call_tool("elicit", {})
|
| 213 |
+
assert result.data == "No name provided."
|
| 214 |
+
|
| 215 |
+
async def test_log_request(self, proxy_server: FastMCP):
|
| 216 |
+
"""
|
| 217 |
+
Test that the proxy client correctly forwards the `log` request.
|
| 218 |
+
"""
|
| 219 |
+
log_handler_called = False
|
| 220 |
+
|
| 221 |
+
async def log_handler(message: LogMessage) -> None:
|
| 222 |
+
nonlocal log_handler_called
|
| 223 |
+
log_handler_called = True
|
| 224 |
+
assert message.data == "Hello, world!"
|
| 225 |
+
assert message.level == "info"
|
| 226 |
+
assert message.logger == "test"
|
| 227 |
+
|
| 228 |
+
async with Client(proxy_server, log_handler=log_handler) as client:
|
| 229 |
+
await client.call_tool(
|
| 230 |
+
"log", {"message": "Hello, world!", "level": "info", "logger": "test"}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
assert log_handler_called
|
| 234 |
+
|
| 235 |
+
async def test_report_progress_request(self, proxy_server: FastMCP):
|
| 236 |
+
"""
|
| 237 |
+
Test that the proxy client correctly forwards the `report_progress` request.
|
| 238 |
+
"""
|
| 239 |
+
|
| 240 |
+
EXPECTED_PROGRESS_MESSAGES = [
|
| 241 |
+
dict(progress=1, total=3, message="33.33% complete"),
|
| 242 |
+
dict(progress=2, total=3, message="66.67% complete"),
|
| 243 |
+
dict(progress=3, total=3, message="100.00% complete"),
|
| 244 |
+
]
|
| 245 |
+
PROGRESS_MESSAGES = []
|
| 246 |
+
|
| 247 |
+
async def progress_handler(
|
| 248 |
+
progress: float, total: float | None, message: str | None
|
| 249 |
+
) -> None:
|
| 250 |
+
PROGRESS_MESSAGES.append(
|
| 251 |
+
dict(progress=progress, total=total, message=message)
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
async with Client(proxy_server, progress_handler=progress_handler) as client:
|
| 255 |
+
await client.call_tool("report_progress", {})
|
| 256 |
+
|
| 257 |
+
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
|
tests/server/test_proxy.py
CHANGED
|
@@ -11,7 +11,7 @@ from fastmcp import FastMCP
|
|
| 11 |
from fastmcp.client import Client
|
| 12 |
from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
-
from fastmcp.server.proxy import FastMCPProxy
|
| 15 |
|
| 16 |
USERS = [
|
| 17 |
{"id": "1", "name": "Alice", "active": True},
|
|
@@ -71,13 +71,13 @@ def fastmcp_server():
|
|
| 71 |
@pytest.fixture
|
| 72 |
async def proxy_server(fastmcp_server):
|
| 73 |
"""Fixture that creates a FastMCP proxy server."""
|
| 74 |
-
return FastMCP.as_proxy(
|
| 75 |
|
| 76 |
|
| 77 |
async def test_create_proxy(fastmcp_server):
|
| 78 |
"""Test that the proxy server properly forwards requests to the original server."""
|
| 79 |
# Create a client
|
| 80 |
-
client =
|
| 81 |
|
| 82 |
server = FastMCPProxy.as_proxy(client)
|
| 83 |
|
|
|
|
| 11 |
from fastmcp.client import Client
|
| 12 |
from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
+
from fastmcp.server.proxy import FastMCPProxy, ProxyClient
|
| 15 |
|
| 16 |
USERS = [
|
| 17 |
{"id": "1", "name": "Alice", "active": True},
|
|
|
|
| 71 |
@pytest.fixture
|
| 72 |
async def proxy_server(fastmcp_server):
|
| 73 |
"""Fixture that creates a FastMCP proxy server."""
|
| 74 |
+
return FastMCP.as_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server)))
|
| 75 |
|
| 76 |
|
| 77 |
async def test_create_proxy(fastmcp_server):
|
| 78 |
"""Test that the proxy server properly forwards requests to the original server."""
|
| 79 |
# Create a client
|
| 80 |
+
client = ProxyClient(transport=FastMCPTransport(fastmcp_server))
|
| 81 |
|
| 82 |
server = FastMCPProxy.as_proxy(client)
|
| 83 |
|