diff --git a/src/fastmcp/client/elicitation.py b/src/fastmcp/client/elicitation.py index d57323718..987cdd012 100644 --- a/src/fastmcp/client/elicitation.py +++ b/src/fastmcp/client/elicitation.py @@ -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( diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 6fb8e5549..5cef53aa7 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -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) diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index e904340db..d65b4a6bf 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -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") diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 04ba6c123..45395a7ef 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -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) diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 1f8f6611c..907574d7b 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -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 diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index f6f7e198e..baf4086a4 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -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) diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py index f0dedf4e0..0ebbf5309 100644 --- a/tests/tools/test_tool_transform.py +++ b/tests/tools/test_tool_transform.py @@ -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): diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index 0fa53cbbd..253b7c452 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -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