MCP Design Patterns: Building Effective Model Context Protocol Servers
MCP Design Patterns
After building production MCP servers for SaaS companies, we've seen the same patterns work and the same mistakes repeat. This guide covers what we've learned about designing MCP servers that are lean, secure, and easy for AI agents to use effectively.
Tool Design: Less Is More
The most common mistake we see is exposing your entire REST API as MCP tools. A product with 50 API endpoints does not need 50 MCP tools.
Why fewer tools work better: Every tool definition consumes tokens in the AI agent's context window. An agent with 50 tools to choose from makes worse decisions than one with 8 well-designed tools. The agent spends tokens parsing tool descriptions instead of doing useful work.
The 5-10 tool rule: Identify the 5-10 most common workflows your users perform and design tools around those workflows, not around your API structure. A single manageSubscription tool that handles upgrades, downgrades, and cancellations is better than three separate tools.
// Bad: mirrors REST endpoints
tools: [
{ name: "getUser", ... },
{ name: "updateUser", ... },
{ name: "deleteUser", ... },
{ name: "getUserSubscription", ... },
{ name: "updateSubscription", ... },
{ name: "cancelSubscription", ... },
{ name: "getUserInvoices", ... },
// ... 40 more
]
// Good: workflow-oriented
tools: [
{ name: "lookupCustomer", ... }, // Search by name, email, or ID
{ name: "manageSubscription", ... }, // View, upgrade, downgrade, cancel
{ name: "getAccountOverview", ... }, // Combined user + billing + usage
{ name: "checkServiceStatus", ... }, // Health and uptime
]Name tools for what users ask, not what APIs do. Users say "look up this customer" not "GET /api/v2/users?email=...". Name your tools accordingly.
Tool Parameters: Be Strict, Be Helpful
Every tool parameter should have:
- A clear description explaining what valid values look like
- Explicit types (avoid
stringwhen you mean"active" | "cancelled" | "paused") - Required vs optional clearly marked
- Default values documented
{
name: "lookupCustomer",
description: "Find a customer by name, email, or account ID. Returns profile, subscription status, and recent activity.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Customer name, email address, or account ID (e.g., 'jane@example.com' or 'acct_12345')"
},
includeActivity: {
type: "boolean",
description: "Include last 10 activity events. Defaults to false to save tokens.",
default: false
}
},
required: ["query"]
}
}Authentication Patterns
MCP servers need auth, but the pattern depends on who's using the server and where it runs.
Pattern 1: API Key in Environment
Best for: developer tools, internal team use, single-tenant deployments.
The MCP server reads an API key from the environment. The key is configured once when the server is set up.
const apiKey = process.env.PRODUCT_API_KEY;
if (!apiKey) throw new Error("PRODUCT_API_KEY is required");
// All tool calls use this key
async function callProductAPI(endpoint: string) {
return fetch(`${BASE_URL}${endpoint}`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
}When to use: The user has their own API key and configures it locally. Simple, no OAuth dance needed.
Pattern 2: OAuth 2.0 with PKCE
Best for: multi-tenant SaaS, user-specific data access, production deployments.
The MCP server implements the OAuth authorization code flow with PKCE. The user authenticates through their browser, the server gets a token, and all subsequent tool calls use that token.
// MCP transport handles the OAuth flow
const transport = new StreamableHTTPServerTransport({
authProvider: {
authorize: async (req) => {
// Redirect to your OAuth server
return `${AUTH_URL}/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT}&response_type=code&code_challenge=${challenge}`;
},
exchange: async (code) => {
// Exchange code for token
const resp = await fetch(`${AUTH_URL}/token`, { ... });
return resp.json();
}
}
});When to use: You need per-user permissions, audit trails, or your product already has OAuth.
Pattern 3: Session Token Passthrough
Best for: products where the user is already authenticated in a browser session.
The MCP server receives a session token from the client and passes it through to your API.
When to use: Internal tools where the user's browser session can be shared with the MCP client.
Error Handling
MCP tools should return structured errors that help the AI agent recover, not stack traces or generic "something went wrong" messages.
The Error Response Pattern
// Bad: raw error
throw new Error("Request failed with status 404");
// Good: structured, actionable error
return {
content: [{
type: "text",
text: JSON.stringify({
error: "customer_not_found",
message: "No customer found matching 'john@example.com'. Try searching by account ID instead.",
suggestions: ["Check spelling", "Use account ID (format: acct_xxxxx)", "Search by company name"]
})
}],
isError: true
};Error Categories
Design your errors around what the agent should do next:
| Error Type | Agent Action | Example |
|---|---|---|
not_found | Try different search terms | "No customer matching that query" |
permission_denied | Tell the user they need access | "You don't have access to billing data" |
rate_limited | Wait and retry | "Rate limit hit, try again in 30 seconds" |
invalid_input | Fix the input and retry | "Email format invalid, expected user@domain" |
server_error | Report to user, don't retry | "Service is temporarily unavailable" |
Response Optimization: Token Efficiency
Every token in the response costs money and consumes context window. Design responses to be information-dense.
Return What's Needed, Not Everything
// Bad: return the full user object with 50 fields
return { content: [{ type: "text", text: JSON.stringify(fullUserObject) }] };
// Good: return only what the agent asked for
return {
content: [{
type: "text",
text: JSON.stringify({
name: user.name,
email: user.email,
plan: user.subscription.plan,
status: user.subscription.status,
mrr: `$${user.subscription.mrr / 100}`,
})
}]
};Use Structured Formats
Structured data (JSON with clear keys) is easier for agents to parse and reason about than prose. But don't over-structure. A flat object with 5 fields beats a nested object with 3 levels.
Pagination and Limits
Never return unbounded lists. Always paginate or limit results:
{
name: "listRecentOrders",
description: "Returns the 10 most recent orders. Use the 'offset' parameter to paginate.",
inputSchema: {
properties: {
limit: { type: "number", default: 10, maximum: 25 },
offset: { type: "number", default: 0 }
}
}
}Testing Patterns
MCP servers need testing at three levels:
1. Tool Unit Tests
Test each tool's logic independently. Mock the external API and verify the response shape.
test("lookupCustomer returns structured response", async () => {
mockAPI.get("/customers?q=jane").reply(200, { data: [mockCustomer] });
const result = await tools.lookupCustomer({ query: "jane" });
expect(result.content[0].type).toBe("text");
const parsed = JSON.parse(result.content[0].text);
expect(parsed.name).toBe("Jane Doe");
expect(parsed.status).toBe("active");
});2. Error Path Tests
Test every error category. The error responses are part of your API contract with the agent.
test("lookupCustomer returns structured error for no results", async () => {
mockAPI.get("/customers?q=nonexistent").reply(200, { data: [] });
const result = await tools.lookupCustomer({ query: "nonexistent" });
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBe("customer_not_found");
expect(parsed.suggestions).toBeDefined();
});3. Integration Tests with a Real Agent
Spin up the MCP server locally, connect a real AI agent, and verify end-to-end workflows. This catches issues that unit tests miss: tool description ambiguity, parameter validation gaps, and response format problems.
Versioning and Backwards Compatibility
MCP servers evolve. Tools get added, parameters change, responses expand. Follow these rules:
- Adding tools is safe. Agents ignore tools they don't know about.
- Removing tools breaks agents. Deprecate first, remove later.
- Adding optional parameters is safe. Always provide defaults.
- Changing response shapes is risky. If agents depend on specific fields, keep them.
Summary
The best MCP servers we've built share these traits:
- 5-10 tools that match user workflows, not API endpoints
- Strict parameter schemas with helpful descriptions
- Structured error responses that tell the agent what to do next
- Token-efficient responses that return only what's needed
- Comprehensive tests covering happy paths and every error category
The goal is not to expose your entire product through MCP. It's to make the 5 most common workflows effortless through an AI agent.