Designing an MCP server for a SaaS product
How to expose a SaaS product to AI assistants over MCP: tool design, auth and tenancy, guardrails, confirmation for risky actions and rate limits.
6 min readmcp · ai agents · typescript · api design · security
Picture a user typing into their AI assistant: "Pause the campaign for fintech founders and show me who replied this week." That's a lookup, a write and a search. If the server behind those tools is designed well, the assistant gets it right. If it isn't, the assistant pauses the wrong campaign and reports success.
At Sendler.ai I designed an MCP server that exposed platform tools to AI assistants, so users could run campaigns, search leads and manage conversations in natural language. The Model Context Protocol gives you the plumbing: tool discovery, typed inputs, transports. The product decisions are still yours, and these are the ones I think matter most.
Design tools for a model, not for a router
An MCP tool is an API whose caller is a language model reading your descriptions. That changes the design:
- Name tools after intents.
search_leads,get_campaign_stats,pause_campaignandreply_to_conversationare easier for a model to choose than a genericupdate_campaignwith a status field. - Keep the set small. Every tool definition costs context on every request, and overlapping tools make selection worse.
- Write descriptions that say when to use the tool, when not to, and what to call first. "Use
search_campaignsto turn a campaign name into an ID before calling this" prevents a lot of guessing. - Return compact, structured results: IDs plus the few fields a person would want, with cursors for pagination. A tool that dumps a whole conversation history fills the context and buries the answer.
- Make errors actionable. "No campaign named 'fintech founders'. Closest matches: ..." lets the model recover. A bare 404 doesn't.
Tenancy comes from the token, never from arguments
For remote servers, the MCP specification builds authorization on OAuth, so the user signs in and grants the assistant access to their workspace. Two rules follow from that.
First, the tenant is derived from the access token on every call. No tool takes an account ID argument. A model can be talked into putting anything into an argument; it can't change the token.
Second, tools should call the same service layer as the product's public REST API. Permissions, validation, audit logging and usage metering then apply to MCP calls exactly as they apply to the dashboard and the API, and there's no second implementation to drift out of sync.
Scopes help as well. Read-only access is something a user might reasonably grant on its own, so it should be a separate scope from writing or sending.
Confirm before anything that can't be undone
I sort tools into three groups:
- Read-only: searching leads, campaign stats, listing conversations.
- Reversible writes: pausing a campaign, tagging a lead.
- Actions with external or irreversible effects: starting a campaign that will contact real people, sending a reply, deleting data.
MCP tool annotations such as readOnlyHint and destructiveHint let clients decide when to ask the user for approval, and newer spec versions let a server request input from the user through the client. Both are useful, and both depend on what the client supports. They are hints, not enforcement.
The portable guarantee lives on the server: a two-step call. The first call returns a preview and a short-lived confirmation token bound to the account, the tool and the exact arguments. The action only runs when the tool is called again with that token.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'outreach', version: '1.0.0' });
const text = (t: string) => ({ content: [{ type: 'text' as const, text: t }] });
server.registerTool(
'start_campaign',
{
title: 'Start campaign',
description:
'Starts sending a campaign to its leads. Call without confirmToken to get a preview, ' +
'show the preview to the user, and call again with confirmToken only if they approve.',
inputSchema: { campaignId: z.string(), confirmToken: z.string().optional() },
annotations: { destructiveHint: true, openWorldHint: true },
},
async ({ campaignId, confirmToken }, extra) => {
const account = await accountFromToken(extra.authInfo); // tenant from auth, never args
await limits.consume(account.id, 'mcp:write');
if (!confirmToken) {
const preview = await campaigns.previewStart(account, campaignId);
const token = await confirmations.issue(account.id, 'start_campaign', { campaignId });
return text(
`This will contact ${preview.leadCount} leads from ${preview.senderName}. ` +
`Ask the user to confirm, then call again with confirmToken "${token}".`,
);
}
await confirmations.redeem(account.id, 'start_campaign', { campaignId }, confirmToken);
await campaigns.start(account, campaignId);
return text(`Campaign ${campaignId} started.`);
},
);redeem fails if the token has expired, has been used, or was issued for different arguments, so a model can't get approval for one campaign and spend it on another.
Rate limits for a caller that loops
Assistants call tools in loops. A model that's unsure will search again with a slightly different query, and an agent can chain many calls for one user request. MCP traffic should draw from the same per-account rate limits and credit-based usage metering as the rest of the platform, with separate budgets for reads and writes, so a chatty search loop can't block a real action.
When a limit is hit, return it as a tool result the model can read, such as "Search limit reached, try again in 30 seconds", rather than a transport error. The assistant can then tell the user what happened instead of failing silently or retrying straight away.
Also cap page sizes on the server. An assistant asking for every lead in the account is a bad request even when it's technically allowed.
Test it like an API, then like a conversation
Two layers of tests cover most of the risk:
- Contract and security tests, written like API tests: each tool validates its input, scopes are enforced, and a token for one account can't read or change another account's data.
- Conversation tests: a set of realistic prompts run against real assistants, checking which tools were called, in what order, and that destructive tools always went through the preview step. These catch bad descriptions faster than any unit test.
import { describe, expect, it } from 'vitest';
describe('tenancy', () => {
it("refuses to read another account's campaign", async () => {
const client = await connectAs('account-a'); // test helper: MCP client using A's token
const result = await client.callTool({
name: 'get_campaign_stats',
arguments: { campaignId: CAMPAIGN_OWNED_BY_B },
});
expect(result.isError).toBe(true);
});
});An MCP server is a new front door to your product, used by a caller that reads instructions literally and never gets tired. Name tools after intents, take the tenant from the token, route every call through the same services and limits as your public API, and require a server-side confirmation for anything that reaches real people or can't be undone. The protocol handles the connection; those decisions are what make it safe to hand to users.