P320 min

Anti-Patterns

The API Wrapper Trap, Tool Inflation, The Monolith, stdout pollution, and missing error context — what to avoid and how to fix it.

On This Page

Key Concepts

  • API Wrapper Trap — adding value beyond pass-through
  • Tool Inflation — fewer, smarter tools beat many simple ones
  • Monolith Server — separation of concerns for MCP
  • Stdout pollution — why console.log kills MCP servers
  • Missing error context — unhelpful errors waste tokens
  • Detecting and fixing anti-patterns in existing servers

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 window

The 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 | remove

Rule 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 domains

The 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 AI

The 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:

  1. Which anti-patterns are present? (There are at least 4)
  2. What would you rename/merge?
  3. What's the most dangerous bug that's likely to happen?
  4. Rewrite the server skeleton with the patterns from this phase.

Check Your Understanding

  1. How do you know if your server has fallen into the API Wrapper Trap?
  2. What is the maximum recommended tool count for an MCP server? Why?
  3. Why is stdout pollution especially dangerous in MCP compared to regular Node.js apps?
  4. Name two symptoms that indicate your server is a monolith.
  5. 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.