Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .changes/unreleased/Bug Fix-20260514-154649.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Bug Fix
body: Fix unstructured tool errors being masked as 'outputSchema defined but no structured output returned'
time: 2026-05-14T15:46:49.572423-07:00
18 changes: 9 additions & 9 deletions src/dbt_mcp/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from dbtlabs_vortex.producer import shutdown
from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel.server import LifespanResultT
from mcp.types import ContentBlock, TextContent, Tool
from mcp.types import CallToolResult, ContentBlock, TextContent, Tool

from dbt_mcp.config.config import Config
from dbt_mcp.dbt_admin.tools import register_admin_api_tools
Expand Down Expand Up @@ -79,9 +79,11 @@ async def _is_multi_project(self) -> bool:
settings.dbt_project_ids is not None and len(settings.dbt_project_ids) > 0
)

async def call_tool(
async def call_tool( # type: ignore[override]
self, name: str, arguments: dict[str, Any]
) -> Sequence[ContentBlock] | dict[str, Any]:
) -> Sequence[ContentBlock] | dict[str, Any] | CallToolResult:
Comment thread
jairus-m marked this conversation as resolved.
Outdated
# Return type includes CallToolResult on error to satisfy outputSchema check
# Parent class, FastMCP.call_tool does not include this type
logger.info(f"Calling tool: {name} with arguments: {_safe_args(arguments)}")
result = None
start_time = int(time.time() * 1000)
Expand All @@ -108,12 +110,10 @@ async def call_tool(
)
except Exception:
logger.debug("Usage tracking failed — skipping", exc_info=True)
return [
TextContent(
type="text",
text=str(e),
)
]
return CallToolResult(
content=[TextContent(type="text", text=str(e))],
isError=True,
)
Comment thread
jairus-m marked this conversation as resolved.
Outdated
end_time = int(time.time() * 1000)
logger.info(f"Tool {name} called successfully in {end_time - start_time}ms")
try:
Expand Down
20 changes: 12 additions & 8 deletions tests/unit/mcp/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from unittest.mock import AsyncMock, MagicMock, patch

from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, Tool
from mcp.types import CallToolResult, TextContent, Tool

from dbt_mcp.errors.common import MissingHostError
from dbt_mcp.config.settings import DbtMcpSettings
Expand Down Expand Up @@ -191,7 +191,7 @@ async def test_routes_based_on_project_mode(
mcps[called].call_tool.assert_awaited_once()
mcps[not_called].call_tool.assert_not_awaited()

async def test_returns_text_content_on_tool_error(self):
async def test_returns_error_result_on_tool_error(self):
multi = MagicMock(spec=FastMCP)
single = MagicMock(spec=FastMCP)
single.call_tool = AsyncMock(side_effect=RuntimeError("something broke"))
Expand All @@ -204,9 +204,11 @@ async def test_returns_text_content_on_tool_error(self):
):
result = await dispatcher.call_tool("bad_tool", {})

assert len(result) == 1
assert isinstance(result[0], TextContent)
assert "something broke" in result[0].text
assert isinstance(result, CallToolResult)
assert result.isError is True
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert "something broke" in result.content[0].text

async def test_tracking_failure_does_not_crash_call_tool(self):
single = MagicMock(spec=FastMCP)
Expand All @@ -224,9 +226,11 @@ async def test_tracking_failure_does_not_crash_call_tool(self):
result = await dispatcher.call_tool("some_tool", {})

# Tool error is returned cleanly despite tracking also failing
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert "DBT_HOST" in result[0].text
assert isinstance(result, CallToolResult)
assert result.isError is True
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert "DBT_HOST" in result.content[0].text
# Tracking was attempted (and failed, but didn't crash)
dispatcher.usage_tracker.emit_tool_called_event.assert_awaited_once()

Expand Down
Loading