Skip to content

[MCP] Crashlytics stack traces are capped at 20 frames with no way to request more #11027

Description

@pascalniklaspaul

Summary

The Crashlytics event tools truncate stack traces to 20 frames, and there is no way for an MCP client to ask for more. For deep native/framework stacks this cuts off exactly the part needed to debug, even though the Crashlytics API returns the complete frames array.

The cap lives in formatFrames in src/mcp/tools/crashlytics/events.ts:

function formatFrames(origFrames, maxFrames = 20) {
    const frames = origFrames || [];
    const shouldTruncate = frames.length > maxFrames;
    const framesToFormat = shouldTruncate ? frames.slice(0, maxFrames - 1) : frames;
    // ...
    if (shouldTruncate) formatted.push("... frames omitted ...");
    return formatted;
}

maxFrames is a default parameter that no call site ever overrides, and neither crashlytics_list_events nor crashlytics_batch_get_events exposes it in its Zod inputSchema. So an agent that needs frame 25 has no way to request it — its only recourse is to abandon the MCP server and call events:batchGet over raw HTTP.

Request: add an optional maxFrames (or frameLimit) parameter to crashlytics_list_events and crashlytics_batch_get_events, plumbed through to formatFrames, so a client can raise the limit when it needs the full trace. Keeping 20 as the default preserves today's context-friendly behaviour.

Related truncation in the same function

These are all in formatFrames/toText and are equally unreachable from a client, so they may be worth addressing in the same change:

  1. Per-frame fields are dropped even when no truncation happens. Only symbol, file and line are printed. The API's library, address, offset, owner and blamed are discarded for every frame — they survive only on the single top-level blameFrame. Without library and address, frames like -[UIApplication _run] carry no binary attribution and manual symbolication is impossible.
  2. breadcrumbs are capped at the last 10 (slice(-10)). Ten breadcrumbs covers only a few seconds of app activity, so the user actions leading up to an error are routinely lost. In the event below the ten returned spanned 38 seconds.
  3. For FATAL/ANR events, all non-crashed threads are discarded via threads.filter((t) => t.crashed || t.blamed). Deadlocks and main-thread hangs cannot be diagnosed without the other threads' stacks.
  4. logs are capped at the last 100 (slice(logs.length - 100)) — the most defensible of the four, but likewise not adjustable.

A single optional "verbosity"/limits parameter covering these would work as well as four separate ones; the key point is that a client currently cannot opt into the complete data.

Bug Info

  • Affected Tool(s): crashlytics_list_events, crashlytics_batch_get_events (both route through formatFrames)
  • MCP Client: Claude Code 2.1.259
  • Operating System: macOS 26.6.2 (build 25G83)

firebase-tools version: 15.29.0 (current latest), launched by the official Firebase Claude Code plugin as npx -y firebase-tools@latest mcp.

Steps to Reproduce

  1. Point the MCP server at a project with Crashlytics data and pick an iOS app.
  2. Find an issue whose sample event has a stack deeper than 20 frames:
    crashlytics_get_report(appId: "<appId>", report: "topIssues", pageSize: 10)
    
  3. Fetch that issue's sample event:
    crashlytics_batch_get_events(appId: "<appId>", names: ["<sampleEvent from step 2>"])
    
  4. Look at the errors (or threads) block in the response. It stops after 19 frames and ends with a literal ... frames omitted ... line. There is no parameter on either tool that raises the limit.

For comparison, the same event fetched directly from the REST endpoint the tool wraps returns the full array:

GET https://firebasecrashlytics.googleapis.com/v1alpha/projects/<projectNumber>/apps/<appId>/events:batchGet?names=<eventName>

so the data is present in the API response and is being dropped by the MCP formatting layer.

Sample response showing the format

An abridged real response (a 20-frame iOS non-fatal, so right at the boundary — the last frame arrives unsymbolicated and prints as a bare at, with library/address unavailable for every frame):

blameFrame: |
  line: '109'
  file: AppDelegate.swift
  symbol: Logger.logPromiseKitEvent(_:)
  offset: '109'
  address: '5829372'
  library: MyApp
  owner: DEVELOPER
  blamed: true
errors: |-
  Error: Non-fatal: Error
  at -[FIRCrashlytics recordError:userInfo:]
  at Logger.logPromiseKitEvent(_:) (AppDelegate.swift:109)
  at closure #1 in CatchMixin.cauterize() (Catchable.swift:191)
  at thunk for @escaping @callee_guaranteed @Sendable () -> () (<compiler-generated>)
  at _dispatch_call_block_and_release
  at _dispatch_client_callout
  at _dispatch_main_queue_drain
  at __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
  at __CFRunLoopRun
  at CFRunLoopRunSpecific
  at GSEventRunModal
  at -[UIApplication _run]
  at UIApplicationMain
  at main (AppDelegate.swift)
  at
threads: |-
  Thread:
  at -[FIRCrashlytics recordError:userInfo:]
  ...

Note that blameFrame carries library, address and offset while none of the stack frames do.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions