Skip to content

Commit 3f487ae

Browse files
fix: wrap get_model_health result in a list to match list[dict] schema (#806)
## Why `get_model_health` was failing response validation on every call with: ``` Input should be a valid list [type=list_type, input_type=dict] ``` The tool is declared `-> list[dict]` with `structured_output=True`, so FastMCP generates a Pydantic output model that expects a JSON array. But `fetch_model_health` returned a bare dict (`edges[0]["node"]`), causing Pydantic to reject the response on every invocation. The underlying data was present — this was purely a response-schema mismatch. ## What Wraps the returned node in a list so the result matches the declared `list[dict]` contract — consistent with how `fetch_model_parents` and `fetch_model_children` return their respective list fields. The empty-model case (`return []`) was already correct. Adds a regression test covering both the found-model case (result is a `list` of length 1) and the empty-model case. Drafted by claude-opus-4-8 under the direction of @theyostalservice --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4ca30a1 commit 3f487ae

5 files changed

Lines changed: 59 additions & 6 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Bug Fix
2+
body: 'Fix get_model_health response schema: wrap single-node result in a list to match the declared list[dict] return type'
3+
time: 2026-06-15T13:42:05.557078-07:00

src/dbt_mcp/config/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class Config:
104104
# product_docs tool actually needs the version, then cached for the
105105
# lifetime of the closure.
106106
dbt_version_provider: Callable[[], str | None] = field(
107-
default_factory=lambda: (lambda: None)
107+
default_factory=lambda: lambda: None
108108
)
109109

110110

src/dbt_mcp/discovery/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ async def fetch_model_health(
577577
edges = result["data"]["environment"]["applied"]["models"]["edges"]
578578
if not edges:
579579
return []
580-
return edges[0]["node"]
580+
return [edges[0]["node"]]
581581

582582

583583
class ExposuresFetcher:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
5+
from dbt_mcp.discovery.client import ModelsFetcher
6+
7+
8+
@pytest.fixture
9+
def models_fetcher():
10+
return ModelsFetcher(paginator=Mock())
11+
12+
13+
async def test_fetch_model_health_wraps_single_node_in_list(
14+
models_fetcher, mock_api_client, unit_discovery_config
15+
):
16+
"""fetch_model_health must return a list[dict], not a bare dict."""
17+
node = {
18+
"uniqueId": "model.project.my_model",
19+
"executionInfo": {"lastSuccessJobDefinitionId": 1, "lastRunStatus": "success"},
20+
"tests": [{"name": "not_null", "status": "pass"}],
21+
"ancestors": [],
22+
}
23+
mock_api_client.return_value = {
24+
"data": {"environment": {"applied": {"models": {"edges": [{"node": node}]}}}}
25+
}
26+
27+
result = await models_fetcher.fetch_model_health(
28+
unique_id="model.project.my_model", config=unit_discovery_config
29+
)
30+
31+
assert isinstance(result, list), (
32+
"fetch_model_health must return a list, not a bare dict"
33+
)
34+
assert len(result) == 1
35+
assert result[0] == node
36+
37+
38+
async def test_fetch_model_health_empty_edges_returns_empty_list(
39+
models_fetcher, mock_api_client, unit_discovery_config
40+
):
41+
"""fetch_model_health returns [] when no model is found."""
42+
mock_api_client.return_value = {
43+
"data": {"environment": {"applied": {"models": {"edges": []}}}}
44+
}
45+
46+
result = await models_fetcher.fetch_model_health(
47+
unique_id="model.project.nonexistent", config=unit_discovery_config
48+
)
49+
50+
assert result == []

tests/unit/lsp/test_lsp_connection.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,8 @@ async def mock_accept_wrapper():
261261
mock_loop.return_value.run_in_executor.return_value = (
262262
mock_accept_wrapper()
263263
)
264-
mock_loop.return_value.create_task.side_effect = (
265-
lambda coro: asyncio.create_task(coro)
264+
mock_loop.return_value.create_task.side_effect = lambda coro: (
265+
asyncio.create_task(coro)
266266
)
267267

268268
await conn.start()
@@ -950,8 +950,8 @@ async def mock_recv_wrapper(size):
950950
):
951951
mock_loop = MagicMock()
952952
mock_get_loop.return_value = mock_loop
953-
mock_loop.run_in_executor.side_effect = (
954-
lambda _, func, *args: mock_recv_wrapper(*args)
953+
mock_loop.run_in_executor.side_effect = lambda _, func, *args: (
954+
mock_recv_wrapper(*args)
955955
)
956956

957957
# Run read loop (will exit when recv returns empty)

0 commit comments

Comments
 (0)