Skip to content

Commit e7c716a

Browse files
authored
fix(dbt_cli, codegen): use returncode for success signal so stderr noise can't mask result (#772)
## Summary **Alternative to #750** — addresses the same urllib3-warning-bleed-through bug, but preserves stderr information on failures rather than discarding it entirely. ### The bug When dbt is invoked with `--quiet` (as the MCP does for build/run/test/etc.), stdout is empty on success. Previously `stderr=subprocess.STDOUT` merged stderr into stdout, so unrelated stderr noise — e.g. urllib3 deprecation warnings emitted under externalbrowser auth — would fill `output` and be returned verbatim to the LLM in place of `"OK"`, confusing the model into thinking the command had failed. ### The fix Capture stdout and stderr separately, and use `process.returncode` as the success signal: - **On success (`returncode == 0`)**: return stdout (often empty under `--quiet`) or `"OK"`. Stderr is dropped — it's typically just noise when the command actually succeeded. - **On failure (`returncode != 0`)**: surface both stdout and stderr in the returned message. Some dbt errors (auth, connection issues, tracebacks) only appear on stderr, so the LLM still gets full diagnostic context. Applied identically to `_run_dbt_command` (`src/dbt_mcp/dbt_cli/tools.py`) and `_run_codegen_operation` (`src/dbt_mcp/dbt_codegen/tools.py`). ### Why an alternative to #750? [#750](#750) takes the simpler approach of switching `stderr` from `STDOUT` to `PIPE` and discarding it. @DevonFulcher [pointed out](#750 (review)) that some stderr content is useful — e.g. error messages that only appear there — and silently dropping it could hide real problems. This PR addresses that concern by keeping stderr available exactly where it's diagnostic (the failure path), while still fixing the original bug. | | #750 | This PR | |---|---|---| | Fixes urllib3 warnings on success | ✅ | ✅ | | Preserves stderr diagnostics on failure | ❌ (always discarded) | ✅ | | Decision signal | "is stdout non-empty" | `returncode == 0` | ## Test plan - [x] `uv run pytest tests/unit/dbt_cli/test_tools.py tests/unit/dbt_codegen/test_tools.py -v` — 63 pass, including 3 new tests for dbt_cli (success with stderr noise, failure with stderr-only error, failure with both streams) and 2 new for codegen (success drops stderr noise, failure surfaces stderr) - [x] `task check` — ruff, mypy, doc generation - [x] `uv run pytest tests/ --ignore=tests/integration` — full unit suite green - [ ] Manual verification: run a real `build`/`run` against a project under externalbrowser auth and confirm `"OK"` is returned instead of urllib3 warning text
1 parent d8f6199 commit e7c716a

8 files changed

Lines changed: 299 additions & 14 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: 'list/run/test/build/codegen tools no longer return stderr noise (e.g. urllib3 deprecation warnings under externalbrowser auth) in place of ''OK'' on success. The fix uses process.returncode as the success signal: on success only stdout is returned, on failure stderr is surfaced alongside stdout so error diagnostics aren''t lost.'
3+
time: 2026-05-12T16:21:50.978253+02:00

src/dbt_mcp/dbt_cli/tools.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,25 @@ def _run_dbt_command(
135135
args=args,
136136
cwd=cwd_path,
137137
stdout=subprocess.PIPE,
138-
stderr=subprocess.STDOUT,
138+
stderr=subprocess.PIPE,
139139
stdin=subprocess.DEVNULL,
140140
text=True,
141141
)
142-
output, _ = process.communicate(timeout=config.dbt_cli_timeout)
143-
return output or "OK"
142+
stdout, stderr = process.communicate(timeout=config.dbt_cli_timeout)
143+
# Use returncode as the success signal so noise on stderr (e.g.
144+
# urllib3 deprecation warnings under externalbrowser auth) can't
145+
# masquerade as the result of a successful command. On failure
146+
# we surface stderr too, since some dbt errors only appear there.
147+
if process.returncode == 0:
148+
return stdout or "OK"
149+
parts = []
150+
if stdout:
151+
parts.append(f"--- stdout ---\n{stdout.rstrip()}")
152+
if stderr:
153+
parts.append(f"--- stderr ---\n{stderr.rstrip()}")
154+
if parts:
155+
return "\n".join(parts)
156+
return f"Command failed with exit code {process.returncode} (no output)"
144157
except subprocess.TimeoutExpired:
145158
return "Timeout: dbt command took too long to complete." + (
146159
" Try using a specific selector to narrow down the results."

src/dbt_mcp/dbt_codegen/tools.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,32 @@ def _run_codegen_operation(
5353
args=args_list,
5454
cwd=cwd_path,
5555
stdout=subprocess.PIPE,
56-
stderr=subprocess.STDOUT,
56+
stderr=subprocess.PIPE,
5757
stdin=subprocess.DEVNULL,
5858
text=True,
5959
)
60-
output, _ = process.communicate(timeout=config.dbt_cli_timeout)
60+
stdout, stderr = process.communicate(timeout=config.dbt_cli_timeout)
6161

62-
# Return the output directly or handle errors
62+
# Use returncode as the success signal so noise on stderr (e.g.
63+
# urllib3 deprecation warnings under externalbrowser auth) can't
64+
# masquerade as the result of a successful command. On failure
65+
# we surface stderr too, since some dbt errors only appear there.
6366
if process.returncode != 0:
64-
if "dbt found" in output and "resource" in output:
65-
return f"Error: dbt-codegen package may not be installed. Run 'dbt deps' to install it.\n{output}"
66-
return f"Error running dbt-codegen macro: {output}"
67-
68-
return output or "OK"
67+
parts = []
68+
if stdout:
69+
parts.append(f"--- stdout ---\n{stdout.rstrip()}")
70+
if stderr:
71+
parts.append(f"--- stderr ---\n{stderr.rstrip()}")
72+
combined = "\n".join(parts)
73+
detail = combined or f"exit code {process.returncode} (no output)"
74+
if "dbt found" in combined and "resource" in combined:
75+
return (
76+
"Error: dbt-codegen package may not be installed. "
77+
f"Run 'dbt deps' to install it.\n{detail}"
78+
)
79+
return f"Error running dbt-codegen macro: {detail}"
80+
81+
return stdout or "OK"
6982

7083
except subprocess.TimeoutExpired:
7184
return f"Timeout: dbt-codegen operation took longer than {config.dbt_cli_timeout} seconds."

tests/unit/dbt_cli/test_cli_integration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def test_dbt_command_execution(self, mock_popen):
1515
"""
1616
# Mock setup
1717
mock_process = MagicMock()
18+
mock_process.returncode = 0
1819
mock_process.communicate.return_value = ("command output", None)
1920
mock_popen.return_value = mock_process
2021

tests/unit/dbt_cli/test_manifest_encoding.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
@pytest.fixture
1111
def mock_process():
1212
class MockProcess:
13+
returncode = 0
14+
1315
def communicate(self, timeout=None):
14-
return "command output", None
16+
return "command output", ""
1517

1618
return MockProcess()
1719

tests/unit/dbt_cli/test_manifest_non_ascii.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
@pytest.fixture
1212
def mock_process():
1313
class MockProcess:
14+
returncode = 0
15+
1416
def communicate(self, timeout=None):
15-
return "command output", None
17+
return "command output", ""
1618

1719
return MockProcess()
1820

tests/unit/dbt_cli/test_tools.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
@pytest.fixture
1515
def mock_process():
1616
class MockProcess:
17+
returncode = 0
18+
1719
def communicate(self, timeout=None):
18-
return "command output", None
20+
return "command output", ""
1921

2022
return MockProcess()
2123

@@ -515,6 +517,135 @@ def mock_popen(args, **kwargs):
515517
assert "--selector" not in args_list
516518

517519

520+
def test_success_returns_ok_even_with_stderr_noise(
521+
monkeypatch: MonkeyPatch, mock_fastmcp
522+
):
523+
"""A successful command (returncode=0) returns 'OK' even when stderr has
524+
noise like urllib3 deprecation warnings — stderr is dropped on success."""
525+
526+
class MockProcessSuccessWithStderrWarning:
527+
returncode = 0
528+
529+
def communicate(self, timeout=None):
530+
return "", "urllib3 v2.0 only supports OpenSSL 1.1.1+: NotOpenSSLWarning"
531+
532+
def mock_popen(args, **kwargs):
533+
return MockProcessSuccessWithStderrWarning()
534+
535+
monkeypatch.setattr("subprocess.Popen", mock_popen)
536+
537+
fastmcp, tools = mock_fastmcp
538+
register_dbt_cli_tools(
539+
fastmcp,
540+
mock_dbt_cli_config,
541+
disabled_tools=set(),
542+
enabled_tools=None,
543+
enabled_toolsets=set(),
544+
disabled_toolsets=set(),
545+
)
546+
547+
assert tools["build"]() == "OK"
548+
549+
550+
def test_failure_surfaces_stderr_when_stdout_is_empty(
551+
monkeypatch: MonkeyPatch, mock_fastmcp
552+
):
553+
"""A failed command (returncode!=0) surfaces stderr — some dbt errors
554+
only appear there, e.g. authentication or connection problems."""
555+
556+
class MockProcessFailureStderrOnly:
557+
returncode = 1
558+
559+
def communicate(self, timeout=None):
560+
return "", "Database Error: could not connect to server"
561+
562+
def mock_popen(args, **kwargs):
563+
return MockProcessFailureStderrOnly()
564+
565+
monkeypatch.setattr("subprocess.Popen", mock_popen)
566+
567+
fastmcp, tools = mock_fastmcp
568+
register_dbt_cli_tools(
569+
fastmcp,
570+
mock_dbt_cli_config,
571+
disabled_tools=set(),
572+
enabled_tools=None,
573+
enabled_toolsets=set(),
574+
disabled_toolsets=set(),
575+
)
576+
577+
result = tools["build"]()
578+
579+
assert "Database Error" in result
580+
assert result != "OK"
581+
582+
583+
def test_failure_with_no_output_surfaces_exit_code(
584+
monkeypatch: MonkeyPatch, mock_fastmcp
585+
):
586+
"""A failed command with empty stdout AND stderr must NOT return 'OK' —
587+
surface the exit code so the LLM can tell the call actually failed."""
588+
589+
class MockProcessFailureNoOutput:
590+
returncode = 137 # e.g. OOM-killed
591+
592+
def communicate(self, timeout=None):
593+
return "", ""
594+
595+
def mock_popen(args, **kwargs):
596+
return MockProcessFailureNoOutput()
597+
598+
monkeypatch.setattr("subprocess.Popen", mock_popen)
599+
600+
fastmcp, tools = mock_fastmcp
601+
register_dbt_cli_tools(
602+
fastmcp,
603+
mock_dbt_cli_config,
604+
disabled_tools=set(),
605+
enabled_tools=None,
606+
enabled_toolsets=set(),
607+
disabled_toolsets=set(),
608+
)
609+
610+
result = tools["build"]()
611+
612+
assert result != "OK"
613+
assert "137" in result
614+
assert "exit code" in result.lower()
615+
616+
617+
def test_failure_combines_stdout_and_stderr(monkeypatch: MonkeyPatch, mock_fastmcp):
618+
"""When both streams have content on failure, both are surfaced."""
619+
620+
class MockProcessFailureBothStreams:
621+
returncode = 1
622+
623+
def communicate(self, timeout=None):
624+
return "Compilation Error in model my_model", "Stack trace here"
625+
626+
def mock_popen(args, **kwargs):
627+
return MockProcessFailureBothStreams()
628+
629+
monkeypatch.setattr("subprocess.Popen", mock_popen)
630+
631+
fastmcp, tools = mock_fastmcp
632+
register_dbt_cli_tools(
633+
fastmcp,
634+
mock_dbt_cli_config,
635+
disabled_tools=set(),
636+
enabled_tools=None,
637+
enabled_toolsets=set(),
638+
disabled_toolsets=set(),
639+
)
640+
641+
result = tools["build"]()
642+
643+
assert "--- stdout ---" in result
644+
assert "Compilation Error" in result
645+
assert "--- stderr ---" in result
646+
assert "Stack trace" in result
647+
648+
518649
def test_clone_command_binary_state_path_logic(
519650
monkeypatch: MonkeyPatch,
520651
mock_process,

tests/unit/dbt_codegen/test_tools.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,126 @@ def mock_popen(args, **kwargs):
285285
assert "Some other error occurred" in result
286286

287287

288+
def test_codegen_success_drops_stderr_noise(monkeypatch: MonkeyPatch, mock_fastmcp):
289+
"""A successful codegen run returns just stdout, ignoring stderr noise
290+
(e.g. urllib3 deprecation warnings under externalbrowser auth)."""
291+
292+
class MockProcessSuccessWithStderrWarning:
293+
returncode = 0
294+
295+
def communicate(self, timeout=None):
296+
return (
297+
"models:\n - name: my_model",
298+
"urllib3 v2.0 only supports OpenSSL 1.1.1+: NotOpenSSLWarning",
299+
)
300+
301+
def mock_popen(args, **kwargs):
302+
return MockProcessSuccessWithStderrWarning()
303+
304+
monkeypatch.setattr("subprocess.Popen", mock_popen)
305+
306+
fastmcp, _ = mock_fastmcp
307+
register_dbt_codegen_tools(
308+
fastmcp,
309+
mock_dbt_codegen_config,
310+
disabled_tools=set(),
311+
enabled_tools=None,
312+
enabled_toolsets=set(),
313+
disabled_toolsets=set(),
314+
)
315+
generate_source_tool = fastmcp.tools["generate_source"]
316+
317+
result = generate_source_tool(
318+
schema_name="test_schema",
319+
database_name=None,
320+
table_names=None,
321+
generate_columns=False,
322+
include_descriptions=False,
323+
)
324+
325+
# Stdout is preserved, stderr warning is dropped.
326+
assert "my_model" in result
327+
assert "urllib3" not in result
328+
329+
330+
def test_codegen_failure_surfaces_stderr(monkeypatch: MonkeyPatch, mock_fastmcp):
331+
"""A failed codegen run surfaces stderr in the error message even when
332+
stdout is empty — some dbt errors only appear on stderr."""
333+
334+
class MockProcessFailureStderrOnly:
335+
returncode = 1
336+
337+
def communicate(self, timeout=None):
338+
return "", "Database Error: relation does not exist"
339+
340+
def mock_popen(args, **kwargs):
341+
return MockProcessFailureStderrOnly()
342+
343+
monkeypatch.setattr("subprocess.Popen", mock_popen)
344+
345+
fastmcp, _ = mock_fastmcp
346+
register_dbt_codegen_tools(
347+
fastmcp,
348+
mock_dbt_codegen_config,
349+
disabled_tools=set(),
350+
enabled_tools=None,
351+
enabled_toolsets=set(),
352+
disabled_toolsets=set(),
353+
)
354+
generate_source_tool = fastmcp.tools["generate_source"]
355+
356+
result = generate_source_tool(
357+
schema_name="test_schema",
358+
database_name=None,
359+
table_names=None,
360+
generate_columns=False,
361+
include_descriptions=False,
362+
)
363+
364+
assert "Error running dbt-codegen macro" in result
365+
assert "Database Error" in result
366+
367+
368+
def test_codegen_failure_with_no_output_surfaces_exit_code(
369+
monkeypatch: MonkeyPatch, mock_fastmcp
370+
):
371+
"""A failed codegen run with no output on either stream must still surface
372+
the exit code so the LLM can distinguish failure from an empty response."""
373+
374+
class MockProcessFailureNoOutput:
375+
returncode = 2
376+
377+
def communicate(self, timeout=None):
378+
return "", ""
379+
380+
def mock_popen(args, **kwargs):
381+
return MockProcessFailureNoOutput()
382+
383+
monkeypatch.setattr("subprocess.Popen", mock_popen)
384+
385+
fastmcp, _ = mock_fastmcp
386+
register_dbt_codegen_tools(
387+
fastmcp,
388+
mock_dbt_codegen_config,
389+
disabled_tools=set(),
390+
enabled_tools=None,
391+
enabled_toolsets=set(),
392+
disabled_toolsets=set(),
393+
)
394+
generate_source_tool = fastmcp.tools["generate_source"]
395+
396+
result = generate_source_tool(
397+
schema_name="test_schema",
398+
database_name=None,
399+
table_names=None,
400+
generate_columns=False,
401+
include_descriptions=False,
402+
)
403+
404+
assert "Error running dbt-codegen macro" in result
405+
assert "exit code 2" in result
406+
407+
288408
def test_codegen_timeout_handling(monkeypatch: MonkeyPatch, mock_fastmcp):
289409
"""Test timeout handling for long-running operations."""
290410

0 commit comments

Comments
 (0)