mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
131 lines
5.5 KiB
Python
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
|