arcade-mcp/libs/tests/arcade_mcp_server/test_debug_exposure.py
Francisco Or Something c866620435
fix(arcade-mcp-server): report missing debug stacktraces (#836)
## Summary
- Return an explicit `[DEBUG] stacktrace: unavailable ...` note when the
stacktrace debug flag is enabled but the tool error payload has no
stacktrace.
- Preserve existing behavior for real stacktraces and for developer
messages, including not leaking developer details unless the
developer-message flag is enabled.
- Clarify the toolkit-author docs around when stacktraces exist, such as
unhandled exceptions or chained `raise ... from exc` errors.

## Test plan
- `pre-commit run --files CLAUDE.md
libs/arcade-mcp-server/arcade_mcp_server/_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure_integration.py`
- `uv run --with pytest --with pytest-asyncio --with pytest-cov pytest
libs/tests/arcade_mcp_server/test_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure_integration.py -v`
- `ruff format --check
libs/arcade-mcp-server/arcade_mcp_server/_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure_integration.py`
- `ruff check
libs/arcade-mcp-server/arcade_mcp_server/_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure.py
libs/tests/arcade_mcp_server/test_debug_exposure_integration.py`

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to debug-only error-message augmentation
when an explicit env flag is enabled; default runtime behavior is
unchanged. Main risk is only in local debugging scenarios where the new
note could affect log parsing or expected error text.
> 
> **Overview**
> When `ARCADE_DEBUG_EXPOSE_STACKTRACE_IN_TOOL_ERROR_RESPONSES` is
enabled, tool error messages now **always include a stacktrace debug
section**: either the actual stacktrace (when present) or an explicit
`[DEBUG] stacktrace: unavailable ...` note when the tool error payload
had no stacktrace.
> 
> Adds/updates unit + integration coverage for the missing-stacktrace
case and adjusts expectations around “flag enabled but no content.”
Updates toolkit-author docs to clarify when stacktraces exist, and bumps
`arcade-mcp-server` patch version to `1.21.2`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7d85196a30d8d29be98ffb252a13ef2a78057742. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-30 20:03:53 -03:00

177 lines
7.6 KiB
Python

"""Tests for the debug-exposure escape hatch in ``arcade_mcp_server/_debug_exposure.py``."""
import logging
import pytest
from arcade_mcp_server import _debug_exposure as debug_exposure
from arcade_mcp_server._debug_exposure import augment_error_message_for_debug
_LEAK_MAGIC = "yes-i-accept-leaking-internals-to-the-agent"
_ENV_DEV_MSG = "ARCADE_DEBUG_EXPOSE_DEVELOPER_MESSAGE_IN_TOOL_ERROR_RESPONSES"
_ENV_STACKTRACE = "ARCADE_DEBUG_EXPOSE_STACKTRACE_IN_TOOL_ERROR_RESPONSES"
@pytest.fixture(autouse=True)
def _reset_leak_warn_state(monkeypatch):
"""Clear the per-process one-shot warning state so each test starts clean.
Both flags emit loud warnings (rejection and activation) one-shot per flag.
Without a reset, later tests would silently lose coverage of those branches
because the module-level tracking sets are already populated from earlier
tests.
"""
monkeypatch.delenv(_ENV_DEV_MSG, raising=False)
monkeypatch.delenv(_ENV_STACKTRACE, raising=False)
debug_exposure._warned_rejected.clear()
debug_exposure._warned_activated.clear()
yield
debug_exposure._warned_rejected.clear()
debug_exposure._warned_activated.clear()
def test_no_leak_by_default():
"""With both flags unset, message must not be augmented."""
out = augment_error_message_for_debug(
"public error",
developer_message="secret internals",
stacktrace="Traceback...\n line",
)
assert out == "public error"
@pytest.mark.parametrize("bad_value", ["true", "1", "yes", "on", "TRUE", "True"])
def test_rejects_boolean_activation(monkeypatch, caplog, bad_value):
"""Any truthy-looking value that isn't the magic string must be rejected."""
monkeypatch.setenv(_ENV_DEV_MSG, bad_value)
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert out == "public error"
assert any(
"set to a truthy value but not to the required" in rec.message for rec in caplog.records
)
def test_rejects_random_non_magic_value(monkeypatch, caplog):
"""A non-boolean-looking value that isn't the magic string is silently off."""
monkeypatch.setenv(_ENV_DEV_MSG, "debug-please")
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert out == "public error"
assert not any(
"set to a truthy value but not to the required" in rec.message for rec in caplog.records
)
def test_developer_message_flag_enabled(monkeypatch, caplog):
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace="trace"
)
assert "public error" in out
assert "[DEBUG] developer_message: secret internals" in out
# Stacktrace flag is off → stacktrace must NOT be in the augmented text.
assert "trace" not in out.replace("public error", "")
assert any("is ENABLED" in rec.message for rec in caplog.records)
def test_stacktrace_flag_enabled(monkeypatch):
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug(
"public error",
developer_message="secret internals",
stacktrace="Traceback (most recent call last):\n File ...",
)
assert "public error" in out
assert "[DEBUG] stacktrace:" in out
assert "File ..." in out
# Developer-message flag off → dev message must NOT leak.
assert "secret internals" not in out
def test_stacktrace_flag_enabled_but_stacktrace_missing(monkeypatch):
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug(
"public error",
developer_message="secret internals",
stacktrace=None,
)
assert "public error" in out
assert "[DEBUG] stacktrace: unavailable" in out
assert "tool error payload did not include one" in out
# Developer-message flag off → dev message must NOT leak.
assert "secret internals" not in out
def test_both_flags_enabled(monkeypatch):
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug(
"public error", developer_message="dev info", stacktrace="trace info"
)
assert "[DEBUG] developer_message: dev info" in out
assert "[DEBUG] stacktrace:\ntrace info" in out
def test_flag_enabled_but_no_content_to_leak(monkeypatch):
"""Developer flag on but developer_message None → only stacktrace absence is reported."""
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
monkeypatch.setenv(_ENV_STACKTRACE, _LEAK_MAGIC)
out = augment_error_message_for_debug("public error", None, None)
assert "[DEBUG] developer_message:" not in out
assert "[DEBUG] stacktrace: unavailable" in out
def test_activation_warning_emitted_once_per_process(monkeypatch, caplog):
"""Second call with the flag on must NOT emit another activation warning."""
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
augment_error_message_for_debug("a", developer_message="dev", stacktrace=None)
first_count = sum("is ENABLED" in r.message for r in caplog.records)
augment_error_message_for_debug("b", developer_message="dev", stacktrace=None)
second_count = sum("is ENABLED" in r.message for r in caplog.records)
assert first_count == 1
assert second_count == 1 # one-shot per process
def test_rejection_does_not_suppress_later_activation_warning(monkeypatch, caplog):
"""Regression: once a truthy-but-non-magic value has been rejected for a
flag, correcting the value to the magic string within the same process
must still emit the critical "ENABLED ... DO NOT USE IN PRODUCTION"
warning. Previously both paths shared one state set, so the activation
warning was silently swallowed in this scenario.
"""
with caplog.at_level(logging.WARNING, logger="arcade_mcp_server._debug_exposure"):
monkeypatch.setenv(_ENV_DEV_MSG, "true")
out_rejected = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert "[DEBUG]" not in out_rejected
rejection_count = sum(
"set to a truthy value but not to the required" in r.message for r in caplog.records
)
assert rejection_count == 1
monkeypatch.setenv(_ENV_DEV_MSG, _LEAK_MAGIC)
out_activated = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert "[DEBUG] developer_message: secret internals" in out_activated
activation_count = sum("is ENABLED" in r.message for r in caplog.records)
assert activation_count == 1, (
"activation warning must fire even after the rejection warning "
"has already been emitted for the same flag in this process"
)
def test_magic_value_ignores_surrounding_whitespace(monkeypatch):
"""Leading/trailing whitespace around the magic string still activates the flag."""
monkeypatch.setenv(_ENV_DEV_MSG, f" {_LEAK_MAGIC} ")
out = augment_error_message_for_debug(
"public error", developer_message="secret internals", stacktrace=None
)
assert "[DEBUG] developer_message: secret internals" in out