mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
Ensure multi-client configurations use new ProxyClient (#1045)
* Validate elicitation responses are dicts * Ensure advanced MCP features are forwarded through proxy * Use proxy client in tests
This commit is contained in:
parent
dba9f2c81f
commit
ef742d9400
8 changed files with 145 additions and 29 deletions
|
|
@ -53,6 +53,11 @@ def create_elicitation_callback(
|
|||
if not isinstance(result, ElicitResult):
|
||||
result = ElicitResult(action="accept", content=result)
|
||||
content = to_jsonable_python(result.content)
|
||||
if not isinstance(content, dict | None):
|
||||
raise ValueError(
|
||||
"Elicitation responses must be serializable as a JSON object (dict). Received: "
|
||||
f"{result.content!r}"
|
||||
)
|
||||
return MCPElicitResult(**result.model_dump() | {"content": content})
|
||||
except Exception as e:
|
||||
return mcp.types.ErrorData(
|
||||
|
|
|
|||
|
|
@ -773,8 +773,6 @@ class MCPConfigTransport(ClientTransport):
|
|||
"""
|
||||
|
||||
def __init__(self, config: MCPConfig | dict):
|
||||
from fastmcp.client.client import Client
|
||||
|
||||
if isinstance(config, dict):
|
||||
config = MCPConfig.from_dict(config)
|
||||
self.config = config
|
||||
|
|
@ -792,9 +790,9 @@ class MCPConfigTransport(ClientTransport):
|
|||
composite_server = FastMCP()
|
||||
|
||||
for name, server in self.config.mcpServers.items():
|
||||
server_client = Client(transport=server.to_transport())
|
||||
composite_server.mount(
|
||||
prefix=name, server=FastMCP.as_proxy(server_client)
|
||||
prefix=name,
|
||||
server=FastMCP.as_proxy(backend=server.to_transport()),
|
||||
)
|
||||
|
||||
self.transport = FastMCPTransport(mcp=composite_server)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ async def test_elicitation_with_no_handler(fastmcp_server):
|
|||
|
||||
async with Client(fastmcp_server) as client:
|
||||
with pytest.raises(ToolError, match="Elicitation not supported"):
|
||||
await client.call_tool("ask_for_name", {})
|
||||
await client.call_tool("ask_for_name")
|
||||
|
||||
|
||||
async def test_elicitation_accept_content(fastmcp_server):
|
||||
|
|
@ -64,7 +64,7 @@ async def test_elicitation_accept_content(fastmcp_server):
|
|||
async with Client(
|
||||
fastmcp_server, elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("ask_for_name", {})
|
||||
result = await client.call_tool("ask_for_name")
|
||||
assert result.data == "Hello, Alice!"
|
||||
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ async def test_elicitation_decline(fastmcp_server):
|
|||
async with Client(
|
||||
fastmcp_server, elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("ask_for_name", {})
|
||||
result = await client.call_tool("ask_for_name")
|
||||
assert result.data == "No name provided."
|
||||
|
||||
|
||||
|
|
@ -600,3 +600,36 @@ class TestPatternMatching:
|
|||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
result = await client.call_tool("pattern_match_tool", {})
|
||||
assert result.data == "Cancelled"
|
||||
|
||||
|
||||
async def test_elicitation_implicit_acceptance(fastmcp_server):
|
||||
"""Test that elicitation handler can return data directly without ElicitResult wrapper."""
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
# Return data directly without wrapping in ElicitResult
|
||||
# This should be treated as implicit acceptance
|
||||
return response_type(name="Bob")
|
||||
|
||||
async with Client(
|
||||
fastmcp_server, elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("ask_for_name")
|
||||
assert result.data == "Hello, Bob!"
|
||||
|
||||
|
||||
async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server):
|
||||
"""Test that elicitation handler can return data directly without ElicitResult wrapper."""
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
# Return data directly without wrapping in ElicitResult
|
||||
# This should be treated as implicit acceptance
|
||||
return "Bob"
|
||||
|
||||
async with Client(
|
||||
fastmcp_server, elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ToolError,
|
||||
match="Elicitation responses must be serializable as a JSON object",
|
||||
):
|
||||
await client.call_tool("ask_for_name")
|
||||
|
|
|
|||
|
|
@ -48,8 +48,7 @@ def run_server(host: str, port: int, **kwargs) -> None:
|
|||
|
||||
|
||||
def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
|
||||
client = Client(transport=StreamableHttpTransport(shttp_url))
|
||||
app = FastMCP.as_proxy(client)
|
||||
app = FastMCP.as_proxy(StreamableHttpTransport(shttp_url))
|
||||
app.run(host=host, port=port, **kwargs)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ async def test_import_with_proxy_tools():
|
|||
def get_data(query: str) -> str:
|
||||
return f"Data for query: {query}"
|
||||
|
||||
proxy_app = FastMCP.as_proxy(Client(api_app))
|
||||
proxy_app = FastMCP.as_proxy(api_app)
|
||||
await main_app.import_server(proxy_app, "api")
|
||||
|
||||
async with Client(main_app) as client:
|
||||
|
|
@ -321,7 +321,7 @@ async def test_import_with_proxy_prompts():
|
|||
"""Example greeting prompt."""
|
||||
return f"Hello, {name} from API!"
|
||||
|
||||
proxy_app = FastMCP.as_proxy(Client(api_app))
|
||||
proxy_app = FastMCP.as_proxy(api_app)
|
||||
await main_app.import_server(proxy_app, "api")
|
||||
|
||||
async with Client(main_app) as client:
|
||||
|
|
@ -349,7 +349,7 @@ async def test_import_with_proxy_resources():
|
|||
"base_url": "https://api.example.com",
|
||||
}
|
||||
|
||||
proxy_app = FastMCP.as_proxy(Client(api_app))
|
||||
proxy_app = FastMCP.as_proxy(api_app)
|
||||
await main_app.import_server(proxy_app, "api")
|
||||
|
||||
# Access the resource through the main app with the prefixed key
|
||||
|
|
@ -376,7 +376,7 @@ async def test_import_with_proxy_resource_templates():
|
|||
def create_user(name: str, email: str):
|
||||
return {"name": name, "email": email}
|
||||
|
||||
proxy_app = FastMCP.as_proxy(Client(api_app))
|
||||
proxy_app = FastMCP.as_proxy(api_app)
|
||||
await main_app.import_server(proxy_app, "api")
|
||||
|
||||
# Instantiate the template through the main app with the prefixed key
|
||||
|
|
|
|||
|
|
@ -777,9 +777,7 @@ class TestProxyServer:
|
|||
return f"Data for {query}"
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(
|
||||
Client(transport=FastMCPTransport(original_server))
|
||||
)
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
|
|
@ -800,9 +798,7 @@ class TestProxyServer:
|
|||
original_server = FastMCP("OriginalServer")
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(
|
||||
Client(transport=FastMCPTransport(original_server))
|
||||
)
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
|
|
@ -832,9 +828,7 @@ class TestProxyServer:
|
|||
return {"api_key": "12345"}
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(
|
||||
Client(transport=FastMCPTransport(original_server))
|
||||
)
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
|
|
@ -856,9 +850,7 @@ class TestProxyServer:
|
|||
return f"Welcome, {name}!"
|
||||
|
||||
# Create proxy server
|
||||
proxy_server = FastMCP.as_proxy(
|
||||
Client(transport=FastMCPTransport(original_server))
|
||||
)
|
||||
proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server))
|
||||
|
||||
# Mount proxy server
|
||||
main_app = FastMCP("MainApp")
|
||||
|
|
@ -914,7 +906,7 @@ class TestAsProxyKwarg:
|
|||
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub)))
|
||||
sub_proxy = FastMCP.as_proxy(FastMCPTransport(sub))
|
||||
|
||||
mcp.mount(sub_proxy, "sub")
|
||||
|
||||
|
|
@ -923,7 +915,7 @@ class TestAsProxyKwarg:
|
|||
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub)))
|
||||
sub_proxy = FastMCP.as_proxy(FastMCPTransport(sub))
|
||||
|
||||
mcp.mount(sub_proxy, "sub", as_proxy=False)
|
||||
|
||||
|
|
@ -932,7 +924,7 @@ class TestAsProxyKwarg:
|
|||
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
|
||||
mcp = FastMCP("Main")
|
||||
sub = FastMCP("Sub")
|
||||
sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub)))
|
||||
sub_proxy = FastMCP.as_proxy(FastMCPTransport(sub))
|
||||
|
||||
mcp.mount(sub_proxy, "sub", as_proxy=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -725,7 +725,7 @@ class TestProxy:
|
|||
def proxy_server(self, mcp_server: FastMCP) -> FastMCP:
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
|
||||
proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(mcp_server)))
|
||||
proxy = FastMCP.as_proxy(FastMCPTransport(mcp_server))
|
||||
return proxy
|
||||
|
||||
async def test_transform_proxy(self, proxy_server: FastMCP):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||
from fastmcp.client.auth.bearer import BearerAuth
|
||||
from fastmcp.client.auth.oauth import OAuthClientProvider
|
||||
from fastmcp.client.client import Client
|
||||
from fastmcp.client.logging import LogMessage
|
||||
from fastmcp.client.transports import (
|
||||
SSETransport,
|
||||
StdioTransport,
|
||||
|
|
@ -195,3 +196,91 @@ async def test_remote_config_with_oauth_literal():
|
|||
client = Client(config)
|
||||
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.transport.auth, OAuthClientProvider)
|
||||
|
||||
|
||||
async def test_multi_client_with_logging(tmp_path: Path):
|
||||
"""
|
||||
Tests that logging is properly forwarded to the ultimate client.
|
||||
"""
|
||||
server_script = inspect.cleandoc("""
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def log_test(message: str, ctx: Context) -> int:
|
||||
await ctx.log(message)
|
||||
return 42
|
||||
|
||||
if __name__ == '__main__':
|
||||
mcp.run()
|
||||
""")
|
||||
|
||||
script_path = tmp_path / "test.py"
|
||||
script_path.write_text(server_script)
|
||||
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"command": "python",
|
||||
"args": [str(script_path)],
|
||||
},
|
||||
"test_server_2": {
|
||||
"command": "python",
|
||||
"args": [str(script_path)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
MESSAGES = []
|
||||
|
||||
async def log_handler(message: LogMessage):
|
||||
MESSAGES.append(message)
|
||||
|
||||
async with Client(config, log_handler=log_handler) as client:
|
||||
result = await client.call_tool("test_server_log_test", {"message": "test 42"})
|
||||
assert result.data == 42
|
||||
assert len(MESSAGES) == 1
|
||||
assert MESSAGES[0].data == "test 42"
|
||||
|
||||
|
||||
async def test_multi_client_with_elicitation(tmp_path: Path):
|
||||
"""
|
||||
Tests that elicitation is properly forwarded to the ultimate client.
|
||||
"""
|
||||
server_script = inspect.cleandoc("""
|
||||
from fastmcp import FastMCP, Context
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
async def elicit_test(ctx: Context) -> int:
|
||||
result = await ctx.elicit('Pick a number', response_type=int)
|
||||
return result.data
|
||||
|
||||
if __name__ == '__main__':
|
||||
mcp.run()
|
||||
""")
|
||||
|
||||
script_path = tmp_path / "test.py"
|
||||
script_path.write_text(server_script)
|
||||
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"command": "python",
|
||||
"args": [str(script_path)],
|
||||
},
|
||||
"test_server_2": {
|
||||
"command": "python",
|
||||
"args": [str(script_path)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return response_type(value=42)
|
||||
|
||||
async with Client(config, elicitation_handler=elicitation_handler) as client:
|
||||
result = await client.call_tool("test_server_elicit_test", {})
|
||||
assert result.data == 42
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue