diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index dee42c271..494678936 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1240,15 +1240,14 @@ async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any], r # Don't mark as successful for error responses - success remains False else: result = response.json() + logger.debug(f"REST API tool response: {result}") filtered_response = extract_using_jq(result, tool.jsonpath_filter) tool_result = ToolResult(content=[TextContent(type="text", text=json.dumps(filtered_response, indent=2))]) success = True - # If output schema is present, validate and attach structured content if getattr(tool, "output_schema", None): valid = self._extract_and_validate_structured_content(tool, tool_result, candidate=filtered_response) success = bool(valid) - elif tool.integration_type == "MCP": transport = tool.request_type.lower() # gateway = db.execute(select(DbGateway).where(DbGateway.id == tool.gateway_id).where(DbGateway.enabled)).scalar_one_or_none() @@ -1405,15 +1404,18 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head tool_call_result = await connect_to_sse_server(tool_gateway.url, headers=headers) elif transport == "streamablehttp": tool_call_result = await connect_to_streamablehttp_server(tool_gateway.url, headers=headers) - content = tool_call_result.model_dump(by_alias=True).get("content", []) - + dump = tool_call_result.model_dump(by_alias=True) + logger.debug(f"Tool call result dump: {dump}") + content = dump.get("content", []) + # Accept both alias and pythonic names for structured content + structured = dump.get("structuredContent") or dump.get("structured_content") filtered_response = extract_using_jq(content, tool.jsonpath_filter) - tool_result = ToolResult(content=filtered_response) - success = True - # If output schema is present, validate and attach structured content - if getattr(tool, "output_schema", None): - valid = self._extract_and_validate_structured_content(tool, tool_result, candidate=filtered_response) - success = bool(valid) + + is_err = getattr(tool_call_result, "is_error", None) + if is_err is None: + is_err = getattr(tool_call_result, "isError", False) + tool_result = ToolResult(content=filtered_response, structured_content=structured, is_error=is_err, meta=getattr(tool_call_result, "meta", None)) + logger.debug(f"Final tool_result: {tool_result}") else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) @@ -1431,7 +1433,11 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Reconstruct ToolResult from modified result modified_result = post_result.modified_payload.result if isinstance(modified_result, dict) and "content" in modified_result: - tool_result = ToolResult(content=modified_result["content"]) + # Safely obtain structured content using .get() to avoid KeyError when + # plugins provide only the content without structured content fields. + structured = modified_result.get("structuredContent") if "structuredContent" in modified_result else modified_result.get("structured_content") + + tool_result = ToolResult(content=modified_result["content"], structured_content=structured) else: # If result is not in expected format, convert it to text content tool_result = ToolResult(content=[TextContent(type="text", text=str(modified_result))]) diff --git a/mcpgateway/transports/streamablehttp_transport.py b/mcpgateway/transports/streamablehttp_transport.py index 599f20f03..596eea0bc 100644 --- a/mcpgateway/transports/streamablehttp_transport.py +++ b/mcpgateway/transports/streamablehttp_transport.py @@ -359,12 +359,25 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent, """ Handles tool invocation via the MCP Server. + This function supports the MCP protocol's tool calling with structured content validation. + It can return either unstructured content only, or both unstructured and structured content + when the tool defines an outputSchema. + Args: name (str): The name of the tool to invoke. arguments (dict): A dictionary of arguments to pass to the tool. Returns: - List of content (TextContent, ImageContent, or EmbeddedResource) from the tool response. + Union[List[ContentBlock], Tuple[List[ContentBlock], Dict[str, Any]]]: + - If structured content is not present: Returns a list of content blocks + (TextContent, ImageContent, or EmbeddedResource) + - If structured content is present: Returns a tuple of (unstructured_content, structured_content) + where structured_content is a dictionary that will be validated against the tool's outputSchema + + The MCP SDK's call_tool decorator automatically handles both return types: + - List return → CallToolResult with content only + - Tuple return → CallToolResult with both content and structuredContent fields + Logs and returns an empty list on failure. Examples: @@ -389,7 +402,31 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent, logger.warning(f"No content returned by tool: {name}") return [] - return [types.TextContent(type=content.type, text=content.text) for content in result.content] + # Normalize unstructured content to MCP SDK types + unstructured = [types.TextContent(type=content.type, text=content.text) for content in result.content] + + # If the tool produced structured content (ToolResult.structured_content / structuredContent), + # return a combination (unstructured, structured) so the server can validate against outputSchema. + # The ToolService may populate structured_content (snake_case) or the model may expose + # an alias 'structuredContent' when dumped via model_dump(by_alias=True). + structured = None + try: + # Prefer attribute if present + structured = getattr(result, "structured_content", None) + except Exception: + structured = None + + # Fallback to by-alias dump (in case the result is a pydantic model with alias fields) + if structured is None: + try: + structured = result.model_dump(by_alias=True).get("structuredContent") if hasattr(result, "model_dump") else None + except Exception: + structured = None + + if structured: + return (unstructured, structured) + + return unstructured except Exception as e: logger.exception(f"Error calling tool '{name}': {e}") return [] diff --git a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py index 3b53c4919..7c61799fe 100644 --- a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py +++ b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py @@ -165,6 +165,10 @@ async def test_call_tool_success(monkeypatch): mock_content.type = "text" mock_content.text = "hello" mock_result.content = [mock_content] + # Ensure no accidental 'structured_content' MagicMock attribute is present + mock_result.structured_content = None + # Prevent model_dump from returning a MagicMock with a 'structuredContent' key + mock_result.model_dump = lambda by_alias=True: {} monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", AsyncMock(return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_db), __aexit__=AsyncMock()))) monkeypatch.setattr(tool_service, "invoke_tool", AsyncMock(return_value=mock_result)) @@ -192,6 +196,11 @@ async def test_call_tool_success(monkeypatch): async def fake_get_db(): yield mock_db + # Ensure no accidental 'structured_content' MagicMock attribute is present + mock_result.structured_content = None + # Prevent model_dump from returning a MagicMock with a 'structuredContent' key + mock_result.model_dump = lambda by_alias=True: {} + monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", fake_get_db) monkeypatch.setattr(tool_service, "invoke_tool", AsyncMock(return_value=mock_result)) @@ -202,6 +211,54 @@ async def fake_get_db(): assert result[0].text == "hello" +@pytest.mark.asyncio +async def test_call_tool_with_structured_content(monkeypatch): + """Test call_tool returns tuple with both unstructured and structured content.""" + # First-Party + from mcpgateway.transports.streamablehttp_transport import call_tool, tool_service, types + + mock_db = MagicMock() + mock_result = MagicMock() + mock_content = MagicMock() + mock_content.type = "text" + mock_content.text = '{"result": "success"}' + mock_result.content = [mock_content] + + # Simulate structured content being present + mock_structured = {"status": "ok", "data": {"value": 42}} + mock_result.structured_content = mock_structured + mock_result.model_dump = lambda by_alias=True: { + "content": [{"type": "text", "text": '{"result": "success"}'}], + "structuredContent": mock_structured + } + + @asynccontextmanager + async def fake_get_db(): + yield mock_db + + monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", fake_get_db) + monkeypatch.setattr(tool_service, "invoke_tool", AsyncMock(return_value=mock_result)) + + result = await call_tool("mytool", {"foo": "bar"}) + + # When structured content is present, result should be a tuple + assert isinstance(result, tuple) + assert len(result) == 2 + + # First element should be the unstructured content list + unstructured, structured = result + assert isinstance(unstructured, list) + assert len(unstructured) == 1 + assert isinstance(unstructured[0], types.TextContent) + assert unstructured[0].text == '{"result": "success"}' + + # Second element should be the structured content dict + assert isinstance(structured, dict) + assert structured == mock_structured + assert structured["status"] == "ok" + assert structured["data"]["value"] == 42 + + @pytest.mark.asyncio async def test_call_tool_no_content(monkeypatch, caplog): """Test call_tool returns [] and logs warning if no content."""