Somewhere in the last year the bottleneck moved. It used to be “can the model reason well enough to do this.” Now the model reasons fine — it just can’t touch anything. Claude Code or Cursor can plan a fix that spans your Firebase data, your Stripe subscriptions, and a Slack channel in one breath, and then it’s stuck, because none of those systems have a door open for it.
MCP is that door. When Anthropic open-sourced the Model Context Protocol on November 25, 2024, the framing was blunt about why:
Even the most sophisticated models are constrained by their isolation from data — trapped behind information silos and legacy systems. Every new data source requires its own custom implementation, making truly connected systems difficult to scale.
The spec’s own analogy is a USB-C port for AI applications: one plug shape instead of an N×M mess of custom adapters. That’s the promise. The catch is that someone still has to build the port for each of your systems — and once you’ve wired one, the real question isn’t “should I build a connector,” it’s “how many of these am I going to end up building.” The honest answer was: all of them, eventually.
So I stopped treating each one as a bespoke project and built a family instead — Firebase, Gmail, Slack, Stripe, GitHub, Typeform, Mailchimp. Different vendors, same shape. Get the shape right once and a new server is a day of wiring an SDK to a router, not a design exercise.
The shape is four decisions
Everything else is vendor-specific detail. These four are the ones that carry the design.
One router, one describeMCP call. Every server sits on edge.libx.js’s RouterWrapper. You define REST routes the normal way — get_db, post_db_push, get_customers — and a single .asMCP() call turns the whole route table into MCP tools. No hand-written tool schemas duplicating your REST layer, no drift between “what the HTTP API does” and “what the agent can call.” The router is the source of truth; MCP is a projection of it.
Dual-mode stdio/HTTP from one binary. Same server, two transports: --stdio for Claude Code and Cursor’s local process model, and plain HTTP with a /api/mcp endpoint when you want it running as a service other agents hit remotely. I didn’t want a “local dev server” and a “hosted server” as two codebases that inevitably diverge — one entry point, one flag.
Read-only by default; writes are an opt-in you have to mean. This is the one that actually matters and the one people skip. An agent with delete/patch/put access to your production Firebase or Stripe account is a footgun with a short fuse — not because the model is malicious, but because it’s confident, and confidence plus a write tool plus a wrong assumption is how you overwrite users/ at 2am. So every server ships read-only. In mcp-firebase, writes (put_db, patch_db, delete_db, post_db_push) are blocked unless readOnly: false is set in config, or MCP_READONLY=false in the env. It’s a boring env var, and it’s the single highest-leverage line in the whole design.
Big responses spool to disk, not into context. The first version of mcp-firebase returned RTDB reads inline. Fine for a handful of keys, catastrophic the first time an agent did a full read on a collection with tens of thousands of children and blew its own context window on its own tool result. Now get_db defaults to shallow=true — a key/type summary inline — and only writes full data to a local cache directory when you ask, returning metadata plus a file path. The agent checks size with get_db_keys first, then decides if it actually wants the payload. It sounds like a caching detail. It’s a context-budget decision, and it’s the difference between a tool that works in the demo and one that survives real use.
What wiring one up looks like
Config lives in a file next to the project (mcp-firebase.json), not baked into the server — so the same binary serves whichever app’s cwd you launch it from:
{
"envPath": "./config/.env.firebase",
"basePath": "/myApp",
"readOnly": true
}
and a global entry in ~/.claude.json (published package shown):
{
"mcpServers": {
"firebase": {
"type": "stdio",
"command": "bunx",
"args": ["-y", "@livx.cc/mcp-firebase", "--stdio"]
}
}
}
Open a different repo with a different mcp-firebase.json, get a different database, same global config. Flip readOnly to false the day you actually trust the flow enough to let it write. Multi-tenancy wasn’t a goal — it was the side effect of refusing to write the same server eight times.
Where this honestly stands
Only @livx.cc/mcp-firebase is published to npm today — bunx -y @livx.cc/mcp-firebase --stdio and you’re running. The other repos (mcp-gmail, mcp-slack, mcp-stripe, mcp-github, mcp-typeform, mcp-mailchimp) aren’t on npm yet — you clone and point Claude Code at the source entry directly, which works fine but means you track a git SHA instead of a semver. That’s not a gap I’m proud of; it’s just where the priority queue left them: publish the one I use daily first, get to the rest when a project forces the issue.
The other honest limit: same shape doesn’t mean same maturity. mcp-firebase has audit logging, pre-write backups, and a restore flow because I’ve actually broken production with it. The newer ones have the router-and-readonly bones but not the same battle scars. Read the repo closest to what you need — they’re all built the same way — and you’ll have the shape of all of them.
One thing I keep going back and forth on: should the read-only-by-default guardrail live in each server, the way it does here, or should the MCP host (Claude Code, Cursor) enforce a write-confirmation gate so no individual server has to be trusted to get it right? Right now every server re-implements the same seatbelt. Where do you think it belongs?
References
- Anthropic — Introducing the Model Context Protocol (Nov 25, 2024): anthropic.com/news/model-context-protocol
- Model Context Protocol — official spec and docs: modelcontextprotocol.io
@livx.cc/mcp-firebaseon npm: npmjs.com/package/@livx.cc/mcp-firebase- The
mcp-*repos on GitHub: github.com/Livshitz