← Blog

2026-02-19

MCP Servers for Your Whole Stack

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