Skip to content
Open
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
8 changes: 8 additions & 0 deletions packages/toolbox-core/src/toolbox_core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ class ToolboxError(Exception):
pass


class ToolInvocationError(ToolboxError):
"""Raised when an MCP server reports that a tool invocation failed."""

def __init__(self, content: str):
self.content = content
super().__init__(content)


class ProtocolNegotiationError(ToolboxError):
"""Raised when the server requires a different protocol version during a stateless request."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from aiohttp import ClientSession

from .. import version
from ..exceptions import ToolInvocationError
from ..itransport import ITransport
from ..protocol import (
AdditionalPropertiesSchema,
Expand Down Expand Up @@ -98,20 +99,26 @@ async def _ensure_initialized(
def base_url(self) -> str:
return self._mcp_base_url

def _process_tool_result_content(self, content: list) -> str:
def _process_tool_result_content(
self, content: list, *, is_error: bool = False
) -> str:
"""Processes the tool result content, handling multiple JSON objects."""
texts = [c.text for c in content if getattr(c, "type", "") == "text"]

result = "".join(texts) or "null"
if len(texts) > 1:
try:
# Check if all chunks are valid JSON objects (dictionaries)
if all(isinstance(json.loads(t), dict) for t in texts):
return f"[{','.join(texts)}]"
result = f"[{','.join(texts)}]"
except (ValueError, TypeError):
# Not valid JSON or not objects, fall back to simple concatenation
pass

return "".join(texts) or "null"
if is_error:
raise ToolInvocationError(result)

return result

def _convert_parameter_schema(
self, name: str, schema: dict, required_fields: list[str]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ async def tool_invoke(
f"Failed to invoke tool '{tool_name}': No response from server."
)

return self._process_tool_result_content(result.content)
return self._process_tool_result_content(
result.content, is_error=result.isError
)
except Exception as e:
error = e
raise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,9 @@ async def tool_invoke(
f"Failed to invoke tool '{tool_name}': No response from server."
)

return self._process_tool_result_content(result.content)
return self._process_tool_result_content(
result.content, is_error=result.isError
)
except Exception as e:
error = e
raise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,9 @@ async def tool_invoke(
f"Failed to invoke tool '{tool_name}': No response from server."
)

return self._process_tool_result_content(result.content)
return self._process_tool_result_content(
result.content, is_error=result.isError
)
except Exception as e:
error = e
raise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,9 @@ async def tool_invoke(
f"Failed to invoke tool '{tool_name}': No response from server."
)

return self._process_tool_result_content(result.content)
return self._process_tool_result_content(
result.content, is_error=result.isError
)
except Exception as e:
error = e
raise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,9 @@ async def tool_invoke(
f"Failed to invoke tool '{tool_name}': No response from server."
)

return self._process_tool_result_content(result.content)
return self._process_tool_result_content(
result.content, is_error=result.isError
)
except Exception as e:
error = e
raise
Expand Down
19 changes: 18 additions & 1 deletion packages/toolbox-core/tests/mcp_transport/test_v20241105.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import pytest_asyncio
from aiohttp import ClientSession

from toolbox_core.exceptions import ProtocolNegotiationError
from toolbox_core.exceptions import ProtocolNegotiationError, ToolInvocationError
from toolbox_core.mcp_transport.v20241105 import types
from toolbox_core.mcp_transport.v20241105.mcp import McpHttpTransportV20241105
from toolbox_core.protocol import ManifestSchema, Protocol
Expand Down Expand Up @@ -461,6 +461,23 @@ async def test_tool_invoke_success(self, transport, mocker):
result = await transport.tool_invoke("tool", {}, {})
assert result == "Result"

async def test_tool_invoke_error_result(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
transport,
"_send_request",
new_callable=AsyncMock,
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="tool failed")],
isError=True,
),
)

with pytest.raises(ToolInvocationError, match="tool failed") as exc_info:
await transport.tool_invoke("tool", {}, {})

assert exc_info.value.content == "tool failed"

async def test_tool_get_success(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
Expand Down
18 changes: 18 additions & 0 deletions packages/toolbox-core/tests/mcp_transport/test_v20250326.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import pytest_asyncio
from aiohttp import ClientSession

from toolbox_core.exceptions import ToolInvocationError
from toolbox_core.mcp_transport.v20250326 import types
from toolbox_core.mcp_transport.v20250326.mcp import McpHttpTransportV20250326
from toolbox_core.protocol import ManifestSchema, Protocol
Expand Down Expand Up @@ -461,6 +462,23 @@ async def test_tool_invoke_success(self, transport, mocker):
result = await transport.tool_invoke("tool", {}, {})
assert result == "Result"

async def test_tool_invoke_error_result(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
transport,
"_send_request",
new_callable=AsyncMock,
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="tool failed")],
isError=True,
),
)

with pytest.raises(ToolInvocationError, match="tool failed") as exc_info:
await transport.tool_invoke("tool", {}, {})

assert exc_info.value.content == "tool failed"

async def test_tool_get_success(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
Expand Down
19 changes: 18 additions & 1 deletion packages/toolbox-core/tests/mcp_transport/test_v20250618.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import pytest_asyncio
from aiohttp import ClientSession

from toolbox_core.exceptions import ProtocolNegotiationError
from toolbox_core.exceptions import ProtocolNegotiationError, ToolInvocationError
from toolbox_core.mcp_transport.v20250618 import types
from toolbox_core.mcp_transport.v20250618.mcp import McpHttpTransportV20250618
from toolbox_core.protocol import ManifestSchema, Protocol, TelemetryAttributes
Expand Down Expand Up @@ -464,6 +464,23 @@ async def test_tool_invoke_success(self, transport, mocker):
result = await transport.tool_invoke("tool", {}, {})
assert result == "Result"

async def test_tool_invoke_error_result(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
transport,
"_send_request",
new_callable=AsyncMock,
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="tool failed")],
isError=True,
),
)

with pytest.raises(ToolInvocationError, match="tool failed") as exc_info:
await transport.tool_invoke("tool", {}, {})

assert exc_info.value.content == "tool failed"

async def test_tool_get_success(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
Expand Down
19 changes: 18 additions & 1 deletion packages/toolbox-core/tests/mcp_transport/test_v20251125.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import pytest_asyncio
from aiohttp import ClientSession

from toolbox_core.exceptions import ProtocolNegotiationError
from toolbox_core.exceptions import ProtocolNegotiationError, ToolInvocationError
from toolbox_core.mcp_transport.v20251125 import types
from toolbox_core.mcp_transport.v20251125.mcp import McpHttpTransportV20251125
from toolbox_core.protocol import ManifestSchema, Protocol
Expand Down Expand Up @@ -491,6 +491,23 @@ async def test_tool_invoke_success(self, transport, mocker):
result = await transport.tool_invoke("tool", {}, {})
assert result == "Result"

async def test_tool_invoke_error_result(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
transport,
"_send_request",
new_callable=AsyncMock,
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="tool failed")],
isError=True,
),
)

with pytest.raises(ToolInvocationError, match="tool failed") as exc_info:
await transport.tool_invoke("tool", {}, {})

assert exc_info.value.content == "tool failed"

async def test_tool_get_success(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
Expand Down
18 changes: 18 additions & 0 deletions packages/toolbox-core/tests/mcp_transport/test_v20260728.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from aiohttp import ClientSession
from aioresponses import aioresponses

from toolbox_core.exceptions import ToolInvocationError
from toolbox_core.mcp_transport.v20260728 import types
from toolbox_core.mcp_transport.v20260728.mcp import McpHttpTransportV20260728
from toolbox_core.protocol import ManifestSchema, Protocol
Expand Down Expand Up @@ -393,6 +394,23 @@ async def test_tool_invoke_success(self, transport, mocker):
result = await transport.tool_invoke("tool", {}, {})
assert result == "Result"

async def test_tool_invoke_error_result(self, transport, mocker):
mocker.patch.object(transport, "_ensure_initialized", new_callable=AsyncMock)
mocker.patch.object(
transport,
"_send_request",
new_callable=AsyncMock,
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="tool failed")],
isError=True,
),
)

with pytest.raises(ToolInvocationError, match="tool failed") as exc_info:
await transport.tool_invoke("tool", {}, {})

assert exc_info.value.content == "tool failed"

async def test_send_request_400_with_json_rpc_error(self, transport):
# Test that an HTTP 400 with a non-negotiation JSON-RPC error is parsed properly.
mock_response = AsyncMock()
Expand Down
Loading