Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions mcpgateway/services/tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")])

Expand All @@ -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))])
Expand Down
41 changes: 39 additions & 2 deletions mcpgateway/transports/streamablehttp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))

Expand All @@ -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."""
Expand Down
Loading