MCP & AI integration

Building a production MCP server for your SaaS: auth, tool design and lessons from ~115 tools

By Waleed Siddiqui, founder & lead engineer

Most MCP tutorials end with a local script that exposes two tools and reads an API key from an environment variable. That is a fine way to learn the protocol. It is not something you can give to customers, or to forty staff who each have different permissions.

We recently built a remote MCP server for a client's operations platform — a leadership-development consultancy that runs invoicing, scheduling, contracts, HR and time tracking on a custom admin app we had already built for them. Staff now connect Claude to it in one click and run routine work by asking. The server exposes roughly 115 tools. This article covers the decisions that mattered, in the order you will hit them.

Remote or local: decide before you write a tool

MCP defines two standard transports. stdio runs the server as a local process the client launches; Streamable HTTP runs it as a web endpoint the client connects to by URL. The protocol messages are the same; almost everything around them is different.

Local (stdio)Remote (Streamable HTTP)
Setup for usersInstall a package, edit a config filePaste a URL, sign in
CredentialsRead from the environment (often a shared API key)OAuth — each connection acts as one user
Who it suitsDevelopers, single-user toolsCustomers, non-technical staff, teams
UpdatesEvery user upgrades their installYou deploy once
Auth spec applies?No — the spec says stdio servers should take credentials from the environmentYes — the MCP authorization spec is written for HTTP transports

Our first version for the client was a local stdio server. It worked, and nobody outside engineering used it. Moving to a hosted endpoint — no install, no config file, sign in with the account you already have — is what turned it from a demo into something staff use. If your users are not developers, start remote.

Authentication: the server acts as a person, not as "the AI"

For HTTP transports, the MCP authorization spec builds on OAuth 2.1. Your MCP server plays the role of an OAuth resource server: it accepts access tokens and serves requests. A separate (or co-hosted) authorization server signs the user in and issues those tokens. The pieces you need to understand:

  • Protected Resource Metadata (RFC 9728). The MCP server must publish metadata telling clients which authorization server to use. An unauthenticated request gets a 401 whose WWW-Authenticate header points at that metadata, and the client takes it from there.
  • Client registration. Clients need a client ID before starting the flow. The current spec prefers Client ID Metadata Documents, allows pre-registration, and keeps Dynamic Client Registration (RFC 7591) for backwards compatibility. Check which mechanisms the clients your users run actually support.
  • PKCE and the resource parameter. Clients use PKCE and must send a resource parameter naming your server, so the token is issued for your server specifically.
  • Audience validation. Your server must reject tokens that were not issued for it. Do not accept, and never pass through, tokens meant for some other API.
  • Scopes. A 403 with error="insufficient_scope" and the required scopes lets clients step up permissions only when needed, instead of asking for everything up front.

If you already have an identity provider, you usually do not need to build an authorization server from scratch — but you do need to check it supports what MCP clients expect. In our build, sign-in reuses the platform's existing accounts, so revoking someone's access to the app also revokes their assistant.

Make tools thin proxies over the API you already trust

The fastest way to create a security hole is to give the MCP server its own database access and re-implement business rules inside tool handlers. Now you have two copies of "who can send an invoice" and they will drift.

Every one of our ~115 tools calls an existing, authenticated endpoint of the platform — the same validation the web app relies on. The MCP layer translates between "what the model asked for" and "what the API accepts", and nothing else. Two consequences:

  • The model cannot do anything the API would reject for that user. Prompt injection can at worst trigger an action the user was already allowed to take — which is why confirmations still matter (below).
  • The server stays small. Adding a tool is mostly writing a description and an input schema, then mapping to an endpoint.

If your product has no clean API yet, build that first. It is the part of the work that pays off regardless of MCP — see our API integration services.

What registering a tool looks like

Here is a minimal, generic example using the official TypeScript SDK (the v2 @modelcontextprotocol/server package). It builds a server per request for the signed-in user, registers one read-only tool, and serves it over Streamable HTTP from a web-standard route handler such as a Next.js App Router route. Check the SDK documentation for your installed version — the SDK moved from @modelcontextprotocol/sdk (v1) to separate server and client packages in v2.

import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { verifyAccessToken } from '@/lib/auth';   // your token + audience check
import { api } from '@/lib/api';                  // your existing, validated API client

function buildServer(user: { id: string; roles: string[] }) {
  const server = new McpServer({ name: 'acme-operations', version: '1.0.0' });

  server.registerTool(
    'list_overdue_invoices',
    {
      description:
        'List invoices in the user\'s workspace that are past their due date. Read-only. ' +
        'Use before drafting reminder emails.',
      inputSchema: z.object({
        olderThanDays: z.number().int().min(1).max(365).default(30),
      }),
    },
    async ({ olderThanDays }) => {
      // Same endpoint the web app uses, called as this user.
      const invoices = await api.invoices.listOverdue({ olderThanDays }, { actingAs: user });
      return { content: [{ type: 'text', text: JSON.stringify(invoices) }] };
    },
  );

  return server;
}

export async function POST(req: Request) {
  const user = await verifyAccessToken(req);   // 401 with WWW-Authenticate if missing/invalid
  if (user instanceof Response) return user;
  return createMcpHandler(() => buildServer(user)).fetch(req);
}

Building the server per request keeps it stateless and makes per-user tool sets trivial: a read-only user simply never gets the write tools registered.

Tool design is prompt design

With a handful of tools, models pick the right one almost every time. With a hundred, tool descriptions and schemas become the main thing standing between "it works" and "it picks the wrong tool and apologises". What helped us most:

  1. Design around jobs, not endpoints. Users ask "chase the overdue invoices", not "GET /invoices?status=overdue". Name and describe tools in the language of the task. Sometimes that means one tool combining two endpoints, or splitting one endpoint into two clearer tools.
  2. Consistent verb_noun names. list_, get_, create_, update_, send_ across every domain. The model learns the pattern; so do your engineers.
  3. Say what the tool is not for. "Read-only", "does not send the email — use send_email after the user confirms" and similar lines prevent a surprising number of mistakes.
  4. Tight input schemas. Enums instead of free strings, bounded numbers, explicit date formats. Every constraint the schema enforces is one the model cannot get wrong.
  5. Compact outputs. Return the fields the model needs to answer and act, not the full database row. Large outputs cost tokens and bury the useful part.

Confirmations, audit logs and limits

Permissions decide what an assistant may do. They do not decide what it should do on a particular request. For anything irreversible or external — sending an email, issuing an invoice, deleting a record — we draft first and ask the user to confirm in the conversation before the write tool runs.

  • Audit every call with user, tool, arguments (minus secrets) and result status. You will need it the first time someone asks "did the assistant send that?"
  • Rate-limit per user, separately from your normal API limits — agents can loop.
  • Treat tool results as untrusted input to the model. Content from emails, documents or customer notes can contain instructions. Keep destructive actions behind confirmation precisely because of this.

Test with the clients your users actually run

Unit tests on tool handlers are necessary and nowhere near sufficient. The failure modes that matter show up only when a real model chooses among your real tools. We kept a list of realistic tasks ("reschedule Thursday's session and tell the participant", "which invoices from last month are unpaid?") and ran them through Claude after every batch of tool changes, watching which tools it chose and where it hesitated. Most fixes were edits to descriptions, not code.

Also test the sign-in flow end to end in each client you support. Client support for the authorization features differs, and it moves quickly.

Deployment: put it next to the API

We hosted the MCP server inside the existing Next.js application as one more route, so it ships with the product, uses the same configuration and secrets management, and cannot fall out of step with the API it proxies. A separate service is reasonable when your backend is not HTTP-friendly or you need independent scaling — but co-location removes a whole class of version-skew bugs.

Plan for maintenance. The MCP specification and SDKs are still evolving, and clients adopt new versions at different speeds. Budget a little time each quarter to track spec changes and re-test with your supported clients.

A checklist before you launch

AreaReady when…
TransportRemote endpoint over Streamable HTTP; users add it by URL
AuthProtected Resource Metadata published; tokens audience-checked; revocation works
PermissionsEach call runs as the connected user; tool set follows their role
ToolsThin proxies over validated endpoints; job-oriented names; tight schemas
SafetyDestructive actions need confirmation; per-user rate limits; full audit log
TestingRealistic task list run in every supported client after each change
OperationsMonitoring and error alerts; owner for spec and SDK upgrades

Read the full case study, or see how we approach MCP server development for SaaS products and internal platforms.

Sources

  1. Model Context Protocol — Authorization specification (2026-07-28)
  2. MCP TypeScript SDK (GitHub)
  3. MCP TypeScript SDK v2 docs — serving over HTTP
  4. RFC 9728 — OAuth 2.0 Protected Resource Metadata
  5. RFC 8707 — Resource Indicators for OAuth 2.0

Questions we get about this

More from the blog

Tell us about the platform you want to connect

Tell us what you are building or what needs fixing. You will hear back from an engineer — not a sales rep — within one business day, usually with questions or a rough approach.

No newsletter, no sales sequence. We use your details only to reply.

DablewDevs

Your enquiry goes straight to our engineering team — the people who will scope and build your project, not a sales desk.

© 2026 DablewDevs. All rights reserved.