Files
openswarm/backend/tests/test_mcp_failure_reason.py

131 lines
5.5 KiB
Python

"""What a user is told when an MCP server dies on them.
The Slack case, captured live: the server exits with a JSON log line carrying a Go goroutine dump,
and the app rendered the whole thing. The user sees "MCP stdio process exited unexpectedly: at
TracingChannel.traceSync (node:diagnostics_channel:322:14) { status: 1, ... }" and has no way to
learn the actual cause, which was simply that their Slack sign-in had expired.
The opposite failure matters too: inventing a friendly reason for something we do not recognise
sends people to fix the wrong thing, so anything unknown keeps its real words.
"""
from backend.apps.tools_lib.mcp_failure_reason import readable_mcp_failure
# Byte-for-byte what slack-mcp-server printed when run with the stored tokens on 2026-08-02.
SLACK_REAL = (
'{"level":"fatal","timestamp":"2026-08-02T21:16:01-07:00","message":'
'"Authentication failed - check your Slack tokens","app":"slack-mcp-server",'
'"error":"invalid_auth","stacktrace":"github.com/korotovsky/slack-mcp-server/pkg/'
'provider.newWithXOXC\\n\\t/Users/runner/work/slack-mcp-server/pkg/provider/api.go:761\\n'
'runtime.main\\n\\t/Users/runner/hostedtoolcache/go/1.25.9/arm64/src/runtime/proc.go:285"}'
)
SLACK_NO_TOKENS = (
'{"level":"fatal","message":"Authentication required: Either SLACK_MCP_XOXP_TOKEN, '
'SLACK_MCP_XOXB_TOKEN, or both SLACK_MCP_XOXC_TOKEN and SLACK_MCP_XOXD_TOKEN must be provided",'
'"app":"slack-mcp-server","stacktrace":"provider.New\\n\\tapi.go:682"}'
)
# Byte-for-byte the FULL stderr of a tokenless `npx -y slack-mcp-server` run (2026-08-04): the Go
# fatal comes FIRST, then the npm wrapper's execFileSync crash dump buries it under ~20 Node lines.
# This is the exact toast Haik and Eric saw; a translator fed only the last few lines can never win.
SLACK_NO_TOKENS_WITH_NPX_DUMP = SLACK_NO_TOKENS + """
node:child_process:963
throw err;
^
Error: Command failed: /Users/x/.npm/_npx/2f12aed4e6049c73/node_modules/slack-mcp-server-darwin-arm64/bin/slack-mcp-server-darwin-arm64 --transport stdio
at genericNodeError (node:internal/errors:983:15)
at wrappedFn (node:internal/errors:537:14)
at checkExecSyncError (node:child_process:924:11)
at Object.execFileSync (node:child_process:960:15)
at Object.<anonymous> (/Users/x/.npm/_npx/2f12aed4e6049c73/node_modules/slack-mcp-server/bin/index.js:64:14)
at Module._compile (node:internal/modules/cjs/loader:1692:14)
at TracingChannel.traceSync (node:diagnostics_channel:322:14) {
status: 1,
signal: null,
output: [ null, null, null ],
pid: 96234,
stdout: null,
stderr: null
}
Node.js v24.4.0"""
def test_the_real_slack_failure_becomes_reconnect_advice():
out = readable_mcp_failure(SLACK_REAL)
assert "sign-in has expired" in out
assert "Reconnect" in out
def test_no_stacktrace_survives_into_the_message():
out = readable_mcp_failure(SLACK_REAL)
for leak in ("goroutine", "github.com", ".go:", "runtime.main", "stacktrace", "{"):
assert leak not in out, f"{leak!r} leaked into what the user reads"
def test_missing_tokens_reads_differently_from_expired_ones():
# Never signed in and signed-in-but-stale need different actions, so they cannot share a string.
never = readable_mcp_failure(SLACK_NO_TOKENS)
expired = readable_mcp_failure(SLACK_REAL)
assert "signed in" in never
assert never != expired
def test_the_npx_crash_dump_never_buries_the_real_reason():
# The regression that survived 1.7.2: the useful fatal line is FIRST and the Node noise last.
out = readable_mcp_failure(SLACK_NO_TOKENS_WITH_NPX_DUMP)
assert "signed in" in out
for leak in ("TracingChannel", "execFileSync", "node:child_process", "status: 1", "Node.js v"):
assert leak not in out, f"{leak!r} leaked into what the user reads"
def test_revoked_access_says_so():
assert "revoked" in readable_mcp_failure('{"error":"token_revoked","message":"bad"}').lower()
def test_a_missing_permission_points_at_reconnecting():
out = readable_mcp_failure('{"error":"missing_scope","message":"needs channels:read"}')
assert "permission" in out and "Reconnect" in out
def test_a_missing_binary_is_named_plainly():
out = readable_mcp_failure("spawn npx ENOENT")
assert "could not be found" in out
def test_rate_limiting_tells_you_to_wait_not_to_reconnect():
out = readable_mcp_failure("Error: rate limited, retry after 30s")
assert "rate-limiting" in out
assert "Reconnect" not in out
def test_an_unknown_json_failure_keeps_its_real_message():
# We must not invent a cause. Surface the server's own sentence, minus the scaffolding.
out = readable_mcp_failure('{"level":"fatal","message":"database is locked","stacktrace":"x.go:1"}')
assert out == "database is locked"
def test_an_unknown_plain_failure_is_passed_through():
assert readable_mcp_failure("Segmentation fault (core dumped)") == "Segmentation fault (core dumped)"
def test_silence_is_reported_as_silence():
out = readable_mcp_failure("")
assert "said nothing" in out
def test_a_novel_string_is_never_guessed_at():
out = readable_mcp_failure("could not bind to port 8080")
assert "sign-in" not in out and "Reconnect" not in out
assert "port 8080" in out
def test_the_last_line_wins_because_the_fatal_one_comes_last():
noisy = '{"level":"info","message":"starting up"}\n{"level":"fatal","message":"disk full"}'
assert readable_mcp_failure(noisy) == "disk full"
def test_output_stays_short_enough_for_a_toast():
assert len(readable_mcp_failure("x" * 5000)) <= 300