Building MCP servers that work is the easy part. Building servers that work well requires knowing what to avoid. This module catalogs the five most common anti-patterns in MCP server development, why they happen, and how to fix them.
Every anti-pattern here comes from real servers in the wild. If you recognize your own code, that's fine — the fix is usually straightforward.
The API Wrapper Trap
The problem: your MCP server is just a thin wrapper around an existing REST API. Every tool maps 1:1 to an API endpoint. The server adds no intelligence, no aggregation, no domain logic.
// ANTI-PATTERN: 1:1 API wrapper
server.tool("get_user", { id: z.string() }, async ({ id }) => {
const res = await fetch(`https://api.example.com/users/${id}`);
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
server.tool("get_user_posts", { id: z.string() }, async ({ id }) => {
const res = await fetch(`https://api.example.com/users/${id}/posts`);
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
// This adds zero value over the AI calling the API directly.The fix: add value. Aggregate data, provide domain logic, handle pagination, format responses for the AI.
// BETTER: Aggregated, AI-optimized response
server.tool("get_user_profile", { id: z.string() }, async ({ id }) => {
const [user, posts, stats] = await Promise.all([
fetchUser(id),
fetchRecentPosts(id, { limit: 5 }),
fetchUserStats(id),
]);
return {
content: [{
type: "text",
text: JSON.stringify({
name: user.name,
email: user.email,
member_since: user.created_at,
recent_posts: posts.map(p => ({
title: p.title,
date: p.published_at,
engagement: p.likes + p.comments,
})),
stats: {
total_posts: stats.post_count,
avg_engagement: stats.avg_engagement,
top_topic: stats.most_frequent_tag,
},
}, null, 2),
}],
};
});Smell test: if you could replace your MCP server with a direct API call and lose nothing, you have the wrapper trap.
Tool Inflation
The problem: your server has 30+ tools. Each one does a single small thing. The AI model struggles to pick the right tool, wastes tokens reading tool definitions, and often calls the wrong one.
// ANTI-PATTERN: 30 individual tools
"create_project", "get_project", "update_project", "delete_project",
"list_projects", "archive_project", "create_task", "get_task",
"update_task", "delete_task", "list_tasks", "assign_task",
"unassign_task", "move_task", "create_comment", "get_comments",
"delete_comment", "create_label", "get_labels", "assign_label",
"remove_label", "create_milestone", "get_milestones", ...
// The AI sees ALL of these in its context windowThe fix: apply the polymorphic pattern from the previous module.
// BETTER: 4 polymorphic tools
"manage_projects" // action: create | get | update | delete | list | archive
"manage_tasks" // action: create | get | update | delete | list | assign | move
"manage_comments" // action: create | list | delete
"manage_labels" // action: create | list | assign | removeRule of thumb: keep your server under 15 tools. If you have more, look for groups that operate on the same entity and merge them.
The Monolith Server
The problem: one MCP server does everything — database queries, API calls, file operations, email sending, deployment, monitoring. It has 50 tools, starts slowly, and a bug in the email handler crashes the database tools.
// ANTI-PATTERN: One server to rule them all
const server = new Server({ name: "everything-server" });
// Database tools
server.tool("query_database", ...);
server.tool("run_migration", ...);
// Email tools
server.tool("send_email", ...);
server.tool("check_inbox", ...);
// Deployment tools
server.tool("deploy_to_production", ...);
server.tool("rollback_deployment", ...);
// File tools
server.tool("read_file", ...);
server.tool("write_file", ...);
// ... 40 more tools across unrelated domainsThe fix: separate by domain. Each MCP server should own one bounded context.
// BETTER: Domain-specific servers
// Server 1: database-server (query, migrate, schema)
// Server 2: email-server (send, check, templates)
// Server 3: deployment-server (deploy, rollback, status)
// Server 4: filesystem-server (read, write, search)
// Claude Desktop config:
{
"mcpServers": {
"database": { "command": "node", "args": ["./servers/database/index.js"] },
"email": { "command": "node", "args": ["./servers/email/index.js"] },
"deploy": { "command": "node", "args": ["./servers/deploy/index.js"] },
"files": { "command": "node", "args": ["./servers/files/index.js"] }
}
}Benefits of separation: independent deployment, isolated failures, clearer tool naming (no need for prefixes), and the AI only loads the tools it needs.
Stdout Pollution
This is the most insidious bug in MCP development. It causes mysterious failures that are incredibly hard to debug.
The problem: MCP's stdio transport uses stdout for JSON-RPC messages between the client and server. If anything else writes to stdout, it corrupts the message stream and the connection dies.
// ANTI-PATTERN: console.log in an MCP server
server.tool("process_data", { input: z.string() }, async ({ input }) => {
console.log("Processing:", input); // THIS KILLS THE SERVER
const result = doSomething(input);
console.log("Result:", result); // THIS ALSO KILLS THE SERVER
return { content: [{ type: "text", text: result }] };
});
// Also dangerous: libraries that log to stdout by default
// - Winston with console transport
// - Debug module
// - ORM query logging
// - Any dependency that calls process.stdout.write()The fix: redirect all logging to stderr.
// GOOD: Use stderr for all logging
const log = (...args: unknown[]) => console.error("[server]", ...args);
// Or redirect stdout entirely at startup
const originalStdout = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk: any, ...args: any[]) => {
// Redirect non-JSON-RPC output to stderr
if (typeof chunk === "string" && !chunk.startsWith("{")) {
return process.stderr.write(chunk, ...args as [any]);
}
return originalStdout(chunk, ...args as [any]);
};
// Best: Use the MCP SDK's built-in logging
server.sendLoggingMessage({
level: "info",
data: "This goes through the protocol, not stdout",
});Debugging tip: if your MCP server connects fine but tools randomly fail or the connection drops mid-conversation, check for stdout pollution first. Add MCP_DEBUG=1 to see raw protocol messages.
Missing Error Context
The problem: errors that tell the AI nothing useful.
// ANTI-PATTERN: Useless error messages
return { content: [{ type: "text", text: "Error" }], isError: true };
return { content: [{ type: "text", text: "Something went wrong" }], isError: true };
return { content: [{ type: "text", text: e.message }], isError: true };
// e.message might be "ECONNREFUSED" — meaningless to the AIThe fix: structured errors with full context (covered in detail in the Error Handling Patterns module).
// GOOD: Actionable error context
return {
content: [{
type: "text",
text: JSON.stringify({
error: {
code: "CONNECTION_FAILED",
message: "Cannot connect to the database at localhost:5432",
cause: "Connection refused — the database server may not be running",
suggestion: "Check if PostgreSQL is running: 'pg_isready -h localhost -p 5432'",
docs: "https://www.postgresql.org/docs/current/server-start.html",
},
}, null, 2),
}],
isError: true,
};Anti-Pattern Detection Matrix
| Symptom | Anti-Pattern | Fix |
|--------------------------------------------|--------------------|----------------------------------|
| Server adds no logic, just forwards calls | API Wrapper Trap | Aggregate, enrich, add domain |
| AI picks wrong tool frequently | Tool Inflation | Merge with polymorphic pattern |
| Bug in one feature crashes unrelated tools | Monolith Server | Split into domain-specific |
| Connection drops randomly mid-conversation | Stdout Pollution | Redirect logging to stderr |
| AI says "an error occurred" with no detail | Missing Context | Structured error responses |
| Server takes 10+ seconds to start | Monolith Server | Split and lazy-load |
| Tool descriptions are paragraphs long | Tool Inflation | Simplify, use fewer tools |Exercise: Audit an MCP Server
Review this server skeleton and identify all anti-patterns:
const server = new Server({ name: "mega-server" });
server.tool("fetch_weather", { city: z.string() }, async ({ city }) => {
console.log("Fetching weather for", city);
const res = await fetch(`https://api.weather.com/v1/${city}`);
return { content: [{ type: "text", text: JSON.stringify(await res.json()) }] };
});
server.tool("fetch_forecast", { city: z.string() }, async ({ city }) => {
const res = await fetch(`https://api.weather.com/v1/${city}/forecast`);
return { content: [{ type: "text", text: JSON.stringify(await res.json()) }] };
});
server.tool("create_user", ...);
server.tool("get_user", ...);
server.tool("update_user", ...);
server.tool("delete_user", ...);
server.tool("list_users", ...);
server.tool("send_notification", { message: z.string() }, async ({ message }) => {
try {
await sendPush(message);
} catch (e) {
return { content: [{ type: "text", text: "Failed" }], isError: true };
}
});Find:
- Which anti-patterns are present? (There are at least 4)
- What would you rename/merge?
- What's the most dangerous bug that's likely to happen?
- Rewrite the server skeleton with the patterns from this phase.
Check Your Understanding
- How do you know if your server has fallen into the API Wrapper Trap?
- What is the maximum recommended tool count for an MCP server? Why?
- Why is stdout pollution especially dangerous in MCP compared to regular Node.js apps?
- Name two symptoms that indicate your server is a monolith.
- What makes an error message “actionable” for an AI agent?
Key Takeaway
Anti-patterns in MCP servers are not just bad style — they directly hurt the AI's ability to use your tools. The wrapper trap wastes the AI's time. Tool inflation confuses it. Monolith servers make your system fragile. Stdout pollution crashes the connection. Missing error context leaves the AI helpless. Learn to recognize these patterns early and fix them before they reach production.