diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 4fa066a43..c2004be7b 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -70,7 +70,6 @@ from .transports import ( PythonStdioTransport, SessionKwargs, SSETransport, - StdioTransport, StreamableHttpTransport, infer_transport, ) @@ -433,9 +432,25 @@ class Client( """ new_client = copy.copy(self) - if not isinstance(self.transport, StdioTransport): - # Reset session state to fresh state - new_client._session_state = ClientSessionState() + # Always reset session state so cloned clients start disconnected and do not + # share lifecycle state with the original instance. + new_client._session_state = ClientSessionState() + + # Reset mutable task tracking state so new client is independent + new_client._task_registry = {} + new_client._submitted_task_ids = set() + + # Create a fresh session kwargs dict so the clone doesn't share + # the original's mutable dict. Rebind the task notification handler + # to the new client if the default handler is in use; preserve any + # custom message handler the user may have set. + new_client._session_kwargs = {**self._session_kwargs} # type: ignore[typeddict-item] + if isinstance( + self._session_kwargs.get("message_handler"), TaskNotificationHandler + ): + new_client._session_kwargs["message_handler"] = TaskNotificationHandler( + new_client + ) new_client.name += f":{secrets.token_hex(2)}" diff --git a/src/fastmcp/client/elicitation.py b/src/fastmcp/client/elicitation.py index 60545a744..0bfa9a203 100644 --- a/src/fastmcp/client/elicitation.py +++ b/src/fastmcp/client/elicitation.py @@ -61,10 +61,18 @@ def create_elicitation_callback( 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}" - ) + # Auto-wrap scalar values for ScalarElicitationType schemas + # (single "value" property). This lets handlers return T directly + # for ctx.elicit("msg", str/int/float/bool). + if isinstance(params, ElicitRequestFormParams) and set( + params.requestedSchema.get("properties", {}).keys() + ) == {"value"}: + content = {"value": content} + else: + raise ValueError( + "Elicitation responses must be serializable as a JSON object (dict). Received: " + f"{result.content!r}" + ) return MCPElicitResult( _meta=result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] action=result.action, diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index 872b1c9d7..77b38e49d 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -363,7 +363,7 @@ class FunctionPrompt(Prompt): return self.convert_result(result) except Exception as e: logger.exception(f"Error rendering prompt {self.name}") - raise PromptError(f"Error rendering prompt {self.name}.") from e + raise PromptError(f"Error rendering prompt {self.name!r}: {e}") from e def register_with_docket(self, docket: Docket) -> None: """Register this prompt with docket for background execution.""" diff --git a/src/fastmcp/resources/base.py b/src/fastmcp/resources/base.py index bd86459b8..70d422dab 100644 --- a/src/fastmcp/resources/base.py +++ b/src/fastmcp/resources/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload @@ -188,6 +189,12 @@ class ResourceResult(pydantic.BaseModel): f"Use ResourceContent({item!r}) to wrap the value." ) return contents + # Auto-serialize JSON-native types to JSON text + if ( + isinstance(contents, dict | list | tuple | int | float | bool) + or contents is None + ): + return [ResourceContent(json.dumps(contents), mime_type="application/json")] raise TypeError( f"contents must be str, bytes, or list[ResourceContent], got {type(contents).__name__}" ) @@ -329,7 +336,30 @@ class Resource(FastMCPComponent): [ResourceContent(raw_value, mime_type=self.mime_type, meta=self.meta)] ) - # ResourceResult.__init__ handles all other normalization + # For JSON-native types (dict, list, tuple, int, float, bool, None), + # serialize and wrap in ResourceContent with the component's meta, + # matching the str/bytes path above so CSP/permissions propagate. + # Exclude list[ResourceContent] which should go through ResourceResult + # normalization below. + if ( + isinstance(raw_value, dict | list | tuple | int | float | bool) + or raw_value is None + ) and not ( + isinstance(raw_value, list) + and raw_value + and isinstance(raw_value[0], ResourceContent) + ): + return ResourceResult( + [ + ResourceContent( + json.dumps(raw_value), + mime_type=self.mime_type or "application/json", + meta=self.meta, + ) + ] + ) + + # All other types fall through to ResourceResult for error handling return ResourceResult(raw_value) @overload diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index f408f4748..7257490f8 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -13,6 +13,7 @@ from pydantic import AnyUrl import fastmcp from fastmcp.client import Client +from fastmcp.client.tasks import TaskNotificationHandler from fastmcp.client.transports import ( ClientTransport, FastMCPTransport, @@ -832,3 +833,34 @@ async def test_client_list_dict_return_type(): async with client: result = await client.call_tool("get_temperatures", {}) assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}] + + +def test_client_new_resets_mutable_task_state(fastmcp_server): + """Client.new() should not share mutable task tracking structures.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + client._task_registry["task-1"] = lambda: None # type: ignore[assignment] # ty:ignore[invalid-assignment] + client._submitted_task_ids.add("task-1") + + clone = client.new() + + assert clone is not client + assert clone._task_registry == {} + assert clone._submitted_task_ids == set() + assert clone._task_registry is not client._task_registry + assert clone._submitted_task_ids is not client._submitted_task_ids + + +def test_client_new_rebinds_default_task_notification_handler(fastmcp_server): + """Client.new() should bind the default task handler to the cloned client.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + handler = client._session_kwargs.get("message_handler") + assert isinstance(handler, TaskNotificationHandler) + + clone = client.new() + + clone_handler = clone._session_kwargs.get("message_handler") + assert isinstance(clone_handler, TaskNotificationHandler) + assert clone_handler is not handler + assert clone_handler._client_ref() is clone diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index 7b8179f82..3ca43a507 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -144,6 +144,24 @@ async def test_elicitation_cancel_action(): class TestScalarResponseTypes: + async def test_scalar_handler_return_is_auto_wrapped(self): + """Scalar handler returns are wrapped as {"value": ...} for scalar schemas.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> str: + result = await context.elicit(message="", response_type=str) + assert isinstance(result, AcceptedElicitation) + assert isinstance(result.data, str) + return result.data + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content="Alice") + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data == "Alice" + async def test_elicitation_no_response(self): """Test elicitation with no response type.""" mcp = FastMCP("TestServer") diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index 0c7590fea..ebfbde63c 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -277,7 +277,7 @@ class TestPromptTypeConversion: with pytest.raises(PromptError) as exc_info: await prompt.render(arguments={"numbers": "not valid json"}) - assert f"Error rendering prompt {prompt.name}" in str(exc_info.value) + assert f"Error rendering prompt {prompt.name!r}" in str(exc_info.value) async def test_json_parsing_fallback(self): """Test that JSON parsing falls back to direct validation when needed.""" diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index c3490abe3..67bedc044 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -66,8 +66,8 @@ class TestFunctionResource: result = await resource._read() assert result.contents[0].content == b"Hello, world!" - async def test_dict_return_raises_type_error(self): - """Returning dict from read() raises TypeError - use ResourceResult.""" + async def test_dict_return_auto_serializes(self): + """Returning dict from read() auto-serializes to JSON.""" def get_data() -> dict: return {"key": "value"} @@ -81,9 +81,10 @@ class TestFunctionResource: result = await resource.read() assert result == {"key": "value"} - # _read() raises TypeError - must return str, bytes, or ResourceResult - with pytest.raises(TypeError, match="must be str, bytes, or list"): - await resource._read() + # _read() auto-serializes dict to JSON text + resource_result = await resource._read() + assert len(resource_result.contents) == 1 + assert '"key"' in str(resource_result.contents[0].content) async def test_error_handling(self): """Test error handling in FunctionResource.""" diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 0507e9188..7d7b70b64 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -208,10 +208,13 @@ class TestResourceResult: assert result.contents[0].content == b"\xff\xfe" assert result.contents[0].mime_type == "application/octet-stream" - def test_init_from_dict_raises_type_error(self): - """Dict input raises TypeError - must use ResourceContent for serialization.""" - with pytest.raises(TypeError, match="must be str, bytes, or list"): - ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + def test_init_from_dict_auto_serializes(self): + """Dict input is auto-serialized to JSON text.""" + result = ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + assert len(result.contents) == 1 + text = str(result.contents[0].content) + assert '"page"' in text + assert '"total"' in text def test_init_from_single_resource_content_raises_type_error(self): """Single ResourceContent raises TypeError - must be in a list.""" @@ -354,3 +357,16 @@ class TestResourceMetaPropagation: result = await client.read_resource_mcp("test://both-meta") assert result.meta == {"result_key": "result_val"} assert result.contents[0].meta == {"item_key": "item_val"} + + async def test_json_native_return_preserves_component_meta(self): + """JSON-native returns should propagate component-level meta to content.""" + mcp = FastMCP() + + @mcp.resource("test://json-meta", meta={"csp": "default-src 'none'"}) + def json_resource() -> dict[str, str]: + return {"hello": "world"} + + async with Client(mcp) as client: + result = await client.read_resource_mcp("test://json-meta") + assert len(result.contents) == 1 + assert result.contents[0].meta == {"csp": "default-src 'none'"}