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:
Jeremiah Lowin 2025-07-05 11:57:03 -04:00 committed by GitHub
commit ef742d9400
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 145 additions and 29 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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