The eighth agent in our 30 agents series is a Maintainer: every hour it reads your Crashlytics reports, picks the one crash worth somebody’s morning, and files it as a GitHub issue. It is the first agent we have built that changes something outside Mume — every one before it produced a file.
That change of category is the whole story. An agent that writes a file into a workspace you own is a convenience. An agent that opens issues in your repository, on a schedule, while you are asleep, is a thing that needs to be right about what it can reach and honest about what it was given.
The credential we could not narrow
The first real question was not how to read crashes. It was what to ask the user for.
Crashlytics data lives behind firebasecrashlytics.googleapis.com. We read its own discovery document to find out which OAuth scopes its read methods accept, rather than guessing, and the answer was two:
auth/firebase View and administer all your Firebase data and settings
auth/cloud-platform See, edit, configure, and delete your Google Cloud dataThere is no read-only variant. To read a crash report, the narrowest thing Google offers is administer all your Firebase data. The consent screen says so in those words, and it is not wrong: that token could deploy hosting or rewrite security rules.
You cannot design your way out of that. What you can do is refuse to pretend it is smaller than it is, and then narrow every other dimension you do control.
Narrowing reach
The agent talks to Crashlytics through a first-party MCP server that runs Google’s own firebase-tools CLI as a child process. That CLI has a flag for restricting which tools it exposes, and it has two flags that look interchangeable and are not.
--only crashlytics sounds like the safe one. We measured it: it yields 25 tools, not 8. The core group loads alongside whatever you ask for, and the core group contains firebase_deploy and firebase_create_project. An agent reading crash reports would have been one hallucinated tool call away from deploying over somebody’s site.
--tools disables detection entirely and loads exactly the names you list. Ours lists seven, and a test asserts the dangerous ones are absent — a test rather than a comment, because the failure mode makes no noise.
Narrowing lifetime
The more interesting half. Every documented way to hand Google’s Application Default Credentials a user identity is a refresh token plus our OAuth client secret. It works. It also puts a credential that never expires, and the secret that renews it, inside a service whose entire job is spawning a child process on behalf of strangers.
The way out is that ADC’s last resort is the GCE metadata server, and the library honours GCE_METADATA_HOST. On a real instance, a metadata server is precisely a thing that holds the durable credential and hands out short-lived tokens on request. So we serve one: a loopback HTTP server per child, on an ephemeral port, answering with the one-hour access token that arrived with this request.
The gateway keeps the refresh token, because the gateway already keeps every other credential and has the key to seal them with. The worst a compromised MCP instance can take is one user’s hour.
Two things cost an hour each to discover, and both are worth writing down. quota_project_id inside the credentials file is ignored — only the environment variable is read, and getting it wrong produces a bare 403 that names neither cause. And /computeMetadata/v1/instancemust answer 200: it is the availability probe, and a 404 there reads as “not on GCE”, so the whole chain gives up before asking for a token and reports that you are not logged into the Firebase CLI.
Four bugs it found in us
We tested this by pointing it at our own crashes. It found a real one — more on that below — but first it found four of ours, and three were the kind that look like something else entirely.
1. A header Google deletes in transit
The first live run came back with no Crashlytics tools at all. The gateway log said the server resolved, the grant was minted, the IAM token attached. The MCP server’s log said:
[firebase] refused — no Google access token — this service reads
one from the x-google-access-token headerWhich reads, unambiguously, as the sender having forgotten to send it. It had not. X-Google-*is a reserved prefix, and Google’s front end strips any request header matching it before it reaches your container. Two of our three headers arrived; the one we had named into Google’s namespace was deleted en route.
Every layer looked correct because the only evidence was an absence. The fix was a rename. The lasting fix was logging which headers did arrive — names only, never values — so the next two-of-three failure is a glance rather than an afternoon.
2. An unanswered tool call bricks a conversation
This one is not specific to this agent and it is the one we were most glad to find.
Booking a recurring schedule asks for approval, above whatever mode the run is in, because a schedule outlives the conversation that created it. So the model emits the tool call and the run parks, waiting.
If nobody answers — the tab closes, stop gets pressed — the transcript is saved with a question and no answer. Providers require every tool_use to be followed by a matching tool_result, so every later message in that session fails, and nothing the user can do from the UI recovers it: sending another message replays the same broken history.
We repair it where history is assembled rather than where it is written, because that also fixes the conversations already broken — including the one that found it. The synthetic result says the call never ran, rather than deleting the request. Deleting would produce an equally valid conversation and a worse one: the model would have no memory of asking, and would carry on believing the schedule was booked.
3. A first-party server that claimed to be connected
Our composer knew about servers the platform runs and treated them as always available, which was true when the platform held every credential. Firebase broke it: the credential is the user’s Google account. The UI showed included in green for a connection that did not exist.
The same code had the opposite bug a day earlier — our audio server, which needs nothing from anyone, was shown as “not connected yet” behind a Connect button with nothing behind it. Both directions now live in the same comment, because from the code they look identical and from the screen they look opposite.
4. An error that kept the status code and threw away the reason
Listing a user’s Firebase projects failed with a 403. Our message said (403). Google’s response body said exactly which API was disabled on exactly which project.
We had discarded the useful half of an upstream error in favour of a number that names nothing. It is a mistake we have made before in this codebase under a different name, which is why the fix comes with a comment pointing at the other one.
One crash per run, and why that is a design
The obvious shape is “read all the crashes, file all the bugs”. It is wrong twice.
A single issue costs most of a run’s step budget: the ranked report, the events behind the chosen issue, the repository mapping, the issue itself, the note back onto Crashlytics. An agent that starts on the second one with four steps left produces half a bug report, which is worse than none.
And it runs hourly. One good issue an hour is twenty-four a day, which is already far more than anybody wants filed against them. The scarcity is not a limitation to engineer around — it is the correct rate.
What makes that safe across runs is a file. state/filed.json maps a Crashlytics issue id to the GitHub issue opened for it, and every id in it is treated as done however loudly that crash keeps happening. Without it, an hourly job files the same crash twelve times before lunch, which is the fastest way to make somebody switch it off forever.
Resumability is the same trick. There is no checkpoint mechanism on our platform and this agent does not need one: state/current.json is written beforethe work that could be interrupted, so a run that dies halfway leaves a note, and an hour later something reads it. The order in the prompt is fixed rather than suggested, because “file then record” duplicates on a retry and “record then file” loses work.
What it actually filed
On its first real run it ranked our open crashes by impacted users, skipped the ones already tracked, and picked a Firestore permission-denied crash: 16 users, 51 events, and a regression — closed in 2024, back since 0.17.0.
The part we did not expect was the correlation. Crashlytics blames a frame inside a Flutter plugin, which is useless. The agent read two sample events, noticed every one of them shared the same three breadcrumbs before the crash —
tap { element: open_drawer }
tap { element: drawer_user_tile }
screen_view { firebase_screen: settings }— and wrote that the crash follows opening Settings from the user tile, so something on that path subscribes to a document the current user cannot read. Then it said plainly which of two explanations it could not distinguish between, and listed what it had not determined.
That last part matters more than the detective work. A bug report that states its guesses as facts costs a maintainer more time than no report at all.
What we would tell you before you connect it
Connecting asks for administrative access to your Firebase data, because Google offers nothing narrower for reading a crash. Our connect screen says that in its own words before the button, rather than letting Google’s consent page be the first place you read it — someone seeing “administer” for the first time there is right to back out.
The token never reaches the model. It goes to one service, which spends it on seven named tools and cannot reach an eighth.
There is also a better design sitting just out of reach. roles/firebasecrashlytics.viewer exists, it is read-only and Crashlytics-only, and a service account granted it on your project would need no consent screen at all. It does not work today: the Crashlytics reports API answers 404 Method not found to service account tokens while succeeding with user tokens. When that changes, the ask drops from administer all your Firebase data to read Crashlytics, and we will take it.
The Maintainer is live at /maintainer. It needs a Google account, a GitHub token, and one line saying which repository your app’s crashes belong to.