How do you build an MCP server for a SaaS product?

You build an MCP server for a SaaS product in seven steps: choose the jobs users want an assistant to do, design one tool per job, implement the tools as a thin layer over your existing API, add OAuth so every call runs with the user's permissions, make write tools safe, log everything, and test with real assistants. The Model Context Protocol itself is the easy part; the official SDKs handle it in a few dozen lines.

This guide is the practical companion to our explainer what is an MCP server, which covers concepts such as tools, resources, transports and why a product might need one. Here we assume you have decided to build and want to do it well. If you would rather hand the job to a team that runs MCP servers in production, see our MCP server development services.

Step Output Typical time
1. Pick jobs A list of 5-15 user jobs, ranked 1-3 days
2. Design tools Names, descriptions, input schemas, output shapes 2-5 days
3. Build the server Remote server over Streamable HTTP calling your API 3-10 days
4. Add OAuth Sign-in, scopes, per-user permission checks 3-10 days
5. Make writes safe Drafts, confirmations, limits 2-5 days
6. Log and monitor Audit log, per-tool metrics, alerts 1-3 days
7. Test with clients Inspector checks, real-assistant test runs 3-7 days

Which tools should your MCP server expose first?

Expose the 5 to 15 jobs your users would actually ask an assistant to do, not a mirror of your REST API. "Find overdue invoices for this client" is a job. GET /invoices with eleven query parameters is an endpoint. Models choose tools by reading their names and descriptions, so a tool list shaped like user intent gets used correctly far more often.

A good way to find the jobs: read support tickets and sales calls for phrases like "can I quickly see" or "I always have to copy", then write down what a user would type into Claude or ChatGPT. Most SaaS products end up with a first set like this:

  • Search and find: search records by text, status, owner or date.
  • Read details: get one record with the fields a person would need.
  • Summarize activity: what changed this week in a project, account or ticket queue.
  • Create drafts: a draft task, reply, invoice or post the user reviews in your app.
  • Update safe fields: status, assignee, tags, due date.

Leave out admin settings, billing changes, bulk operations and deletion in the first version.

How do you design MCP tools that models use correctly?

Design each tool like a small public API for a reader who has never seen your product: a verb-noun name, a description that says what it does and when to use it, a typed input schema with descriptions on every field, and a compact output.

Rule Weak Better
Name by intent get_entities search_tasks
Say when to use it "Gets tasks" "Search tasks in the user's workspace by text, status or assignee. Use before updating a task to find its id."
Constrain inputs status: string status: "open" or "in_progress" or "done"
Limit output Full objects with 80 fields id, title, status, due date, URL; max 20 results
Recoverable errors Error 422 "Project not found. Call list_projects to get valid project ids."

Return IDs and URLs in results so the model can chain calls and the user can open the record in your app. Keep outputs short: every token a tool returns is context the model must read and the user pays for. Mark read-only tools with the read-only annotation so clients can treat them as safe.

What does a minimal MCP server look like in TypeScript?

Here is a minimal remote server using the official TypeScript SDK with Express, with one read tool and one draft tool. It creates a server per request with the authenticated user baked in, so every tool call runs with that user's permissions. The SDK's API has changed between versions, so check the current SDK docs for exact imports and method names before copying.

import express from "express";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { verifyAccessToken, api, type User } from "./app.js"; // your token check and API client

function buildServer(user: User): McpServer {
  const server = new McpServer({ name: "acme-projects", version: "1.0.0" });

  server.registerTool(
    "search_tasks",
    {
      title: "Search tasks",
      description:
        "Search tasks in the user's workspace by text, status or assignee. " +
        "Returns up to 20 tasks with id, title, status, due date and URL.",
      inputSchema: {
        query: z.string().min(2).describe("Words to find in task titles and descriptions"),
        status: z.enum(["open", "in_progress", "done"]).optional(),
        limit: z.number().int().min(1).max(20).default(10),
      },
      annotations: { readOnlyHint: true },
    },
    async ({ query, status, limit }) => {
      const tasks = await api.searchTasks(user, { query, status, limit });
      return { content: [{ type: "text", text: JSON.stringify(tasks) }] };
    },
  );

  if (user.scopes.includes("tasks:write")) {
    server.registerTool(
      "create_task_draft",
      {
        title: "Create task draft",
        description:
          "Create a DRAFT task in a project. The user reviews and publishes it in the app. " +
          "Never assigns, notifies or publishes.",
        inputSchema: {
          projectId: z.string().describe("Project id from list_projects"),
          title: z.string().min(3).max(200),
          description: z.string().max(5000).optional(),
        },
        annotations: { readOnlyHint: false, destructiveHint: false },
      },
      async (args) => {
        const draft = await api.createTaskDraft(user, args);
        if (!draft.ok) {
          return { isError: true, content: [{ type: "text", text: draft.error }] };
        }
        return {
          content: [{ type: "text", text: `Draft ${draft.id} created. Review it at ${draft.url}` }],
        };
      },
    );
  }

  return server;
}

const app = express();
app.use(express.json());

app.get("/.well-known/oauth-protected-resource", (_req, res) => {
  res.json({
    resource: "https://mcp.example.com/mcp",
    authorization_servers: ["https://auth.example.com"],
    scopes_supported: ["tasks:read", "tasks:write"],
  });
});

app.post("/mcp", async (req, res) => {
  const user = await verifyAccessToken(req.headers.authorization);
  if (!user) {
    res
      .status(401)
      .set("WWW-Authenticate",
        'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"')
      .end();
    return;
  }

  const server = buildServer(user);
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  res.on("close", () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000);

Notice what is not in the MCP layer: business rules and permission logic. api.searchTasks and api.createTaskDraft call the same service layer or REST API your web app uses, so rules live in one place. Note also that the write tool is not registered at all for users without the write scope; a model cannot call a tool it never sees.

If your back end is PHP, the same structure works with a PHP MCP SDK (an official one is being developed together with the Symfony team; check its current status), or with a small TypeScript service like this one in front of your PHP API. Python teams can use the official Python SDK.

How do you add OAuth to a remote MCP server?

Treat your MCP server as an OAuth-protected resource. When a request arrives without a valid token, return 401 with a pointer to your protected resource metadata; the client reads it, finds your authorization server, signs the user in and comes back with a token. Your server then validates the token on every request and acts as that user.

What you need in practice:

  • An authorization server. Your existing identity provider, if it supports the flows MCP clients use, or a small OAuth layer in front of your user accounts. Check the current MCP authorization specification for required flows, including how clients register.
  • Scopes that match tool groups, for example tasks:read and tasks:write, so customers can grant read-only access.
  • Per-call permission checks in your API, using the user's identity from the token. Never use a shared admin key behind the scenes.
  • Revocation from your app's settings page, so users can disconnect an assistant.

For an internal server used by your own team or agents, scoped API keys per person are a reasonable shortcut.

How do you make write tools safe?

Make write tools create drafts or reversible changes, cap how much one call can do, and keep irreversible actions out or behind an explicit confirmation. The server enforces these limits, because the model reads untrusted text (emails, documents, web pages) that may contain instructions aimed at it.

  • Drafts instead of instant publishing. A draft post, reply or invoice that the user publishes in your app.
  • Soft deletes only, if deletion is offered at all.
  • Caps. One record per call for writes, a maximum number of writes per minute per user.
  • No money moves and no messages to third parties without a confirmation step in your interface.
  • Tenant isolation tests. Automated tests that one organization can never reach another's data through any tool.

We learned the drafts rule from our own systems. We run MCP servers in production for our internal project board and our site's content system, used daily by our team and our AI coding agents. That daily use taught us that write tools which publish immediately should have been drafts.

What should you log and monitor?

Log every tool call with user, organization, client name, tool, arguments (with personal data masked), result size, duration and outcome. That log answers support questions ("what did my assistant change?"), shows which tools models misuse, and is your audit trail.

Watch per-tool error rates, calls per user per minute, the share of calls that end in an error the model could not recover from, and tools that are never called. A tool that is never chosen usually has a description that does not match how users phrase requests.

How do you test an MCP server with Claude and ChatGPT?

Test in three layers: protocol checks, scripted tool tests, and real-assistant runs.

  1. MCP Inspector. Run npx @modelcontextprotocol/inspector and connect it to your server. Check that every tool lists with the right schema, returns valid results and returns readable errors for bad input.
  2. Automated tests. Call tool handlers directly in your test suite with users of different roles and tenants, including the cross-tenant cases.
  3. Real assistants. Add the server as a custom connector in Claude and in ChatGPT's developer mode (menu names change; check each client's current docs), and in a developer tool such as Cursor or Visual Studio Code. Run 30-50 realistic requests and record the results in a table like this illustrative one:
Request Expected tool Claude ChatGPT
"What is overdue in the Apollo project?" search_tasks with status and project correct correct
"Draft a task to renew the SSL certificate" create_task_draft correct wrong project id
"Delete all done tasks" none; explain it is not possible refused refused

Each client selects and calls tools slightly differently, so a description that works in one may confuse another. Fix descriptions, re-run the full set, and keep it as a regression suite.

Our verdict for a first SaaS MCP server: a remote server over Streamable HTTP, OAuth with read and write scopes, 5 to 15 intent-shaped tools over your existing API, drafts for every write, a full audit log, and a test set run against at least two real assistants. Start read-only if your permission model is complex, and add writes once you have seen how users use it.

A focused first server like this usually fits into one AI Sprint: $10,000 fixed for 4 weeks. Comparing vendors? See our list of MCP server development companies.

Next step

Send us your API documentation and five things your users would ask an assistant to do in your product. In a 30-minute call we will propose a first tool set and flag the permission questions. See our MCP server development services or contact us.

Case studies

Frequently asked questions

For a product with a clean API, a first remote MCP server with a focused set of tools, OAuth and logging typically takes 2 to 6 weeks. The protocol layer is a few days of work. Most of the time goes into choosing tools, writing descriptions models understand, authorization, safe write behavior and testing with several AI clients.

Use the language of your existing API if an official SDK exists for it. The TypeScript and Python SDKs are the most mature, and official SDKs exist or are being developed for several other languages, including PHP. Because most SaaS MCP servers are a thin layer over an existing API, a separate small TypeScript service in front of a PHP or Java back end also works well.

For a server your customers connect from Claude, ChatGPT or other assistants, use OAuth. It lets each user sign in with their own account, gives you scopes and revocation, and is what major MCP clients expect for remote servers. Static API keys are acceptable for internal servers and for developer tools where each engineer manages their own key.

Rarely, and never by default. Start with read tools, then add write tools that create drafts or reversible changes the user confirms in your app. Irreversible, bulk or money-moving actions should be excluded or require an explicit confirmation step. The server, not the model, must enforce these limits, because a model can be manipulated by text it reads.

Start with MCP Inspector to check that every tool lists correctly and returns valid results and errors. Then connect the server to real clients such as Claude and ChatGPT and run 30-50 realistic user requests, checking which tool was chosen, with which arguments, and whether the answer was right. Repeat the run after every change to tool names or descriptions.

Let’s start your project
Book a call