arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/_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

87 lines
3.4 KiB
Python

"""
Debug-only escape hatch for MCP tool error responses.
MCP clients typically render only the ``message`` field of a tool error
response, dropping ``developer_message`` and ``stacktrace``. That makes
server-side iteration painful when a tool is failing. The flags in this
module let a toolkit author opt in to appending those internals to the
``message`` field while debugging.
DEBUG-ONLY. Activating these flags can leak paths, tokens, or PII to
callers. Don't add more flags of this shape — put debug info in logs
instead.
"""
from __future__ import annotations
import logging
import os
_logger = logging.getLogger(__name__)
# Acknowledgement string a developer must set as the env value. Picked to be
# impossible to set by mistake — no sane config management or CI will ever
# emit this string.
_DEBUG_LEAK_MAGIC = "yes-i-accept-leaking-internals-to-the-agent"
_ENV_EXPOSE_DEVELOPER_MESSAGE = "ARCADE_DEBUG_EXPOSE_DEVELOPER_MESSAGE_IN_TOOL_ERROR_RESPONSES"
_ENV_EXPOSE_STACKTRACE = "ARCADE_DEBUG_EXPOSE_STACKTRACE_IN_TOOL_ERROR_RESPONSES"
# One-shot warning state per flag. The rejection warning (truthy but not the
# magic string) and the activation warning (magic string set) are tracked in
# *separate* sets so that fixing a misconfigured flag within the same process
# still fires the critical activation warning.
_warned_rejected: set[str] = set()
_warned_activated: set[str] = set()
def _leak_enabled(env_var: str) -> bool:
raw = os.environ.get(env_var)
if raw is None:
return False
if raw.strip() != _DEBUG_LEAK_MAGIC:
# A value is set but it isn't the magic ack. Treat as off and, if it
# looks like someone tried a boolean, nudge them via a log so the
# silence isn't confusing.
if raw.strip().lower() in {"1", "true", "yes", "on"} and env_var not in _warned_rejected:
_warned_rejected.add(env_var)
_logger.warning(
"%s is set to a truthy value but not to the required "
"acknowledgement string. Flag remains OFF. "
"See arcade_mcp_server/_debug_exposure.py.",
env_var,
)
return False
if env_var not in _warned_activated:
_warned_activated.add(env_var)
_logger.warning(
"%s is ENABLED. Tool error internals will be appended to the "
"`message` field of MCP tool error responses. This can leak paths, "
"tokens, or PII to callers. DO NOT USE IN PRODUCTION.",
env_var,
)
return True
def augment_error_message_for_debug(
message: str,
developer_message: str | None,
stacktrace: str | None,
) -> str:
"""Append debug internals to ``message`` when the corresponding env flags are set.
This is a no-op in the default case (both flags off), and also a no-op when
the flags are set to anything other than the activation ack string. See
module docstring for the full rationale.
"""
extras: list[str] = []
if developer_message and _leak_enabled(_ENV_EXPOSE_DEVELOPER_MESSAGE):
extras.append(f"developer_message: {developer_message}")
if _leak_enabled(_ENV_EXPOSE_STACKTRACE):
if stacktrace:
extras.append(f"stacktrace:\n{stacktrace}")
else:
extras.append("stacktrace: unavailable (tool error payload did not include one)")
if not extras:
return message
return f"{message}\n\n[DEBUG] " + "\n\n[DEBUG] ".join(extras)