Drumbeats/Blog/Article

Article

How to Build an MCP Server for Your SaaS: What Shipping Ours Taught Me

The stdio server took one day to code. Everything that made it good took three weeks. A development story about API prep, tool design, OAuth surprises, and why agents read schemas, not READMEs.

How to Build an MCP Server for Your SaaS: What Shipping Ours Taught Me
July 13, 202610 min readmcpai agentsdeveloper toolsopen source

Last month I shipped an MCP server for Drumbeats. The code for the first working version took one day. The three weeks around that day are where everything useful happened — and that's what this post is about.

This is not a feature announcement. The repo is public if you want the details. This is the part I wish someone had written before I started: what to do before you write MCP code, how to design tools that agents can actually use, what your OAuth setup is almost certainly missing, and why a registry quality score jumped from 87 to 98 for reasons that had nothing to do with adding features.

If you run a SaaS and you're thinking about an MCP server, this is the playbook I'd hand you.

Start with the verb, not the transport

The first note in our design doc says:

An MCP that cannot create monitors is pointless.

Drumbeats is a monitoring product. The whole reason to connect it to an AI agent is being able to say "set up monitoring for my nightly backup job" and have it happen. If the MCP could only read data, it would be a demo, not a product.

So before any talk of transports, SDKs, or OAuth, I picked the flagship verb: create_monitor. Every later decision — which endpoints to expose, which scopes to require, what to cut — got tested against whether it served that verb.

Your product has its own verb. For an invoicing tool it's "send an invoice", for a CI product it's "run the pipeline". Name it first. If your MCP can't do it, the rest doesn't matter.

Prepare your API before you write a line of MCP code

Here's the unglamorous part nobody talks about: the MCP server is a thin client. Almost all of the real work happens in your existing API — and your existing API was probably designed for humans with sessions, not agents with keys.

Ours was. Two things were missing, and I fixed both in the core API before the MCP repo existed:

Account-scoped API keys. Our keys were per-project. An agent acting for a user needs to see what the user sees — every project, one key. If your keys are scoped narrower than a user's actual identity, agents will constantly hit walls that make your MCP feel broken.

Per-key permission scopes. One key with read can look but not touch. Add manage_monitors and it can create and edit. Deletes sit behind a scope nobody gets by default. This matters more for MCP than for classic API usage, because the caller is a language model — you want least-privilege to be enforced by the backend, not by hoping the agent behaves.

The key property of this prep: it was transport-agnostic. Nothing in it knew or cared whether an MCP would talk stdio or HTTP. That neutrality paid for itself later.

Tools are not endpoints

My first instinct was to map REST endpoints to tools one-to-one. That instinct was wrong, and catching it early shaped the whole surface.

I started by listing every single endpoint an API key could reach — the real routes, from the router files, not from documentation — in one big table. Then I cut, hard. Three patterns emerged:

Merge endpoints into task-shaped tools. We have four separate endpoints for a monitor's history: pings, warnings, check results, response-time stats. An agent doesn't want four tools; it wants one question answered:

get_monitor_history({ id, kind: "pings" | "warnings" | "checks" | "response_times" })

Same story for incidents — get, acknowledge, and resolve collapsed into one manage_incident tool with an action argument. Fewer tools means less for the model to choose between, and choice is where models make mistakes.

Make invalid states unrepresentable. create_monitor handles four monitor types, and each type has fields the others must not have. Instead of one loose schema and a server that returns 400s, the schema is a discriminated union:

create_monitor(
    { type: "JOB_CRON",    schedule: "0 3 * * *", grace: "15m", ... }
  | { type: "UPTIME_HTTP", url: "https://...", expected_status: 200, ... }
)

The model physically cannot send a cron schedule on an uptime check. Every invalid combination you make unrepresentable is an error message you never have to write and a retry loop the agent never enters.

Cut things that feel obligatory. There is no delete_monitor in v1. Not because it's hard — it's one endpoint — but because an agent misfiring a delete is a much worse failure than an agent not being able to delete. We also dropped a list_channel_types lookup tool entirely and baked the vocabulary into a tool description instead. Static data doesn't deserve a round-trip.

One tool layer, two transports

The architecture decision I'd defend the hardest: tools are defined once, in a transport-blind layer, and both entrypoints consume it.

                ┌──────────────────────────┐
                │    shared tool layer     │
                │  one file per tool,      │
                │  knows nothing about     │
                │  transport or auth       │
                └────────────┬─────────────┘
              ┌──────────────┴──────────────┐
      ┌───────▼────────┐           ┌────────▼────────┐
      │    stdio.ts    │           │  server-http.ts │
      │  npx, local,   │           │  hosted, OAuth  │
      │  your API key  │           │  2.1 in front   │
      └───────┬────────┘           └────────┬────────┘
              │                             │
              └────────► REST API ◄─────────┘
                   (the same public API
                    every customer uses)

The contract that makes it work is small. Every tool handler receives a context that carries an already-authenticated API client, and that is all it may touch:

registerTools(server, ctx)
// ctx = { api }  — an authed REST client

// stdio.ts:       ctx.api built from a local API key (env var)
// server-http.ts: ctx.api built per-session from the OAuth token

A handler never knows how the request arrived or how the user proved who they are. Which means adding a tool is a one-file change that ships to both transports simultaneously, and the transports themselves stay boring — a few hundred lines each.

Why two transports at all? Because they reach different people. stdio (npx -y @drumbeats/mcp) is for developers in IDEs and Claude Desktop. But stdio is invisible to claude.ai on the web, to mobile, to anything that can't spawn a local process. The hosted Streamable HTTP endpoint is the only way to reach those users. If you only ship stdio, you've built a developer tool. Fine — but know you chose it.

Leave the door unlocked for strangers

My favorite small decision: three of our tools require no account at all. check_http, check_ssl, and check_dns are live diagnostics — they run against any URL and need zero credentials.

That means someone who has never heard of Drumbeats can add the MCP server and immediately ask:

"Is example.com's SSL certificate about to expire?"

…and get a real answer. No signup, no API key, no OAuth dance. The tool works, builds a little trust, and then the natural next question — "okay, now alert me before it expires" — is the one that needs an account.

If your product has any capability that can safely run keyless, expose it. It's the difference between an MCP server that demands commitment upfront and one that introduces itself politely. Agents amplify this: an agent evaluating tools will actually call the free ones, and a successful first call is the best onboarding you'll ever write.

Your OAuth is not ready (mine wasn't)

If you ship the hosted transport, your MCP server becomes an OAuth 2.1 resource server, and MCP clients are demanding guests. Our identity service already had OAuth — authorization code flow, PKCE support, refresh token rotation, replay protection. Solid, I thought.

It was solid for our own first-party app. For a public MCP client, five gaps showed up, and I'd bet money most SaaS OAuth implementations share them:

  1. The token endpoint required a client secret. MCP clients are public clients — they have no secret. You need a PKCE-required, secret-free path.
  2. No discovery metadata. Clients find your authorization server via /.well-known/ documents (RFC 8414 + 9728). We served none.
  3. Client registration required a login. MCP clients self-register dynamically (RFC 7591), anonymously, before any user exists.
  4. Tokens had no audience binding. Our JWTs carried no aud claim, so a token minted for the web app was indistinguishable from one minted for the MCP. That's a confused-deputy vulnerability waiting to happen — this one is non-negotiable.
  5. Token scopes weren't enforced by the REST layer. A bearer token was treated as a full-power human session, which would have made the MCP's least-privilege scoping decorative.

The happy-path flow, once it all works:

client ──── POST /mcp (no token) ───────────► MCP server
client ◄─── 401 + "here's where auth lives" ─┘
client ──── discover → register → authorize (user's browser) → token
client ──── POST /mcp + Bearer token ───────► verify sig + exp + aud + scope
                                              └─► tool call runs as that user

One more lesson that only surfaced in live testing: real clients are stricter than the spec. The MCP SDK fetches discovery documents in a specific order and treats a 5xx on any candidate URL as fatal. The spec says dynamic registration is a "SHOULD"; real clients treat it as a MUST. I lost most of a day to gaps like these, watching an actual client's handshake fail — the spec document alone would never have shown them. Budget roughly a week for OAuth work, and verify with a real client, not with the RFC open in a tab.

Agents read tools/list, not your README

Here's the thing that reframed how I think about MCP quality. Your README is for humans. An agent connecting to your server sees exactly one thing: the tools/list response. Names, descriptions, schemas, annotations. That is your product's UI now.

We shipped v0.1 with what I'd call "developer-complete" schemas — correct, minimal. Then I went back and treated the schemas as product surface:

  • Annotations on every tool — readOnlyHint, openWorldHint — so clients know what's safe to call freely.
  • Server instructions — a short orientation string the client receives on connect: which tool to call first for context, which tools work without a key.
  • Output schemas and structured content on every tool, so an agent knows the shape of what comes back before calling — and can chain tools without guessing.
  • A description on every parameter of the flagship tools, including units, formats, and what happens when a field is omitted.

None of this added a feature. Smithery — which scans your server and scores its tool quality — moved us from 87 to 98 on that work alone, and Glama's letter grades came back straight A's for the same reason. The scores are nice; what they measure is real. Every ambiguity in a schema is a place an agent will eventually guess wrong, and an agent that guesses wrong blames your product, in front of your user.

One more schema trick that earns its keep: when a user hits a plan limit, the tool doesn't return a bare error code. It returns a message written for the agent to relay:

if (apiError === PLAN_LIMIT)
  return "This account hit the free plan's monitor limit.
          To add more, upgrade at drumbeats.io/pricing."

The agent turns that into a helpful sentence instead of a stack trace. Errors are UX too — more than ever, when the thing reading them writes prose.

The meta part

I'll admit the part that made this project fun: I built a tool for AI agents with an AI agent. Claude Code was alongside me the whole way — pressure-testing the tool surface while it was still a table in a design doc, and then, as the first real user, failing in exactly the ways the schemas deserved. There's no faster feedback loop than your user sitting inside your terminal.

That's also why the schema-quality section above is the one I'd underline twice. I watched an agent use this thing for three weeks. The difference between a vague parameter description and a precise one isn't cosmetic — it's visible within minutes, in what the agent does.

The playbook

If you're building an MCP server for your own SaaS, the compressed version:

  1. Name your flagship verb first. An MCP that can't do your product's core action is a demo.
  2. Fix your API before writing MCP code — account-scoped keys, per-key scopes, enforced server-side.
  3. Design tools from your real routes, then cut hard. Merge endpoints into task-shaped tools. Make invalid inputs unrepresentable. Skip deletes in v1.
  4. One shared tool layer, thin transport entrypoints. Never fork the tool definitions.
  5. Expose whatever can run keyless. A free first call is your best onboarding.
  6. Audit your OAuth against the MCP profile early — public clients, discovery, dynamic registration, audience binding, scope enforcement. Then verify with a real client.
  7. Treat schemas as product surface. Descriptions, annotations, output schemas, instructions. Agents read tools/list, not your README.
  8. Write errors for the agent to relay, not for a log file.

The whole server is open source — github.com/drumbeats-io/mcp, Apache-2.0. Read it, fork it, or steal the structure for your own product. And if you just want to try the keyless bit: add the server, ask about any site's SSL cert, and see what an unlocked door feels like.

Keep following Drumbeats

Prefer a feed reader or want to share this post with your team? The archive stays on permanent Drumbeats URLs and every article is available through standard feeds.