teachyou.ai academy
← All posts
MCP

Common MCP Server Mistakes (and How to Avoid Them)

Pramod Dutta · Jun 17, 2026 · 14 min read

Why MCP servers fail in ways your unit tests never catch

Model Context Protocol servers look deceptively simple the first time you build one. You write a function, wrap it in a schema, expose it as a tool, and an AI agent can suddenly read your database, call your internal APIs, or trigger a deployment. The demo works in five minutes. Then it goes to production, a real agent starts calling it under real conditions, and everything that seemed fine in isolation starts breaking in ways that are hard to trace.

The reason is that MCP servers sit in an unusual position. They are not quite a REST API, not quite a CLI, not quite a library. They are consumed by a language model that reads your tool descriptions as instructions, decides which tool to call based on fuzzy reasoning rather than a compiler, and passes arguments that are "mostly" correct rather than strictly typed. Every mistake you would normally catch with a linter or a type checker instead shows up as an agent silently doing the wrong thing, calling a tool ten times when it meant to call it once, or hallucinating a parameter that doesn't exist.

This article walks through the mistakes we see most often when engineers build MCP servers, why each one causes real damage, and the concrete pattern that fixes it. If you're building or maintaining MCP servers for internal tools, customer-facing agents, or developer tooling, treat this as a pre-launch checklist as much as a lessons-learned post.

Mistake 1: Tool descriptions written for humans, not for models

The single most common mistake is treating the description field on a tool as documentation rather than as a prompt. Engineers write descriptions the way they'd write a docstring for a teammate: terse, assuming context, leaving out edge cases because "obviously that's how it works."

A model doesn't have your team's tribal knowledge. It only has the schema in front of it. If your description says "Get user data" with no mention of what "user" means in your system, whether it takes an ID or an email, or what happens when the user doesn't exist, the model will guess. Sometimes it guesses right. Often it doesn't, and you get a support ticket that looks like a bug in the model when it's actually a bug in your schema.

{
  "name": "get_user",
  "description": "Get user data",
  "inputSchema": {
    "type": "object",
    "properties": {
      "id": { "type": "string" }
    },
    "required": ["id"]
  }
}

Compare that to a description written with the model as the actual reader:

{
  "name": "get_user",
  "description": "Look up a single user account by their internal user ID (format: usr_xxxxxxxx, not an email address). Returns profile fields, subscription tier, and account status. Returns a 404-style error object if the ID does not exist -- check for an 'error' field in the response before assuming success.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "type": "string",
        "description": "Internal user ID, always prefixed with 'usr_'. Get this from search_users if you only have an email or name."
      }
    },
    "required": ["id"]
  }
}

The second version tells the model what format to expect, what NOT to pass (an email), how to recover if it only has a name, and how to detect failure. Every one of those details prevents a class of bad tool calls. Write every tool description assuming the reader has never seen your codebase and cannot ask a follow-up question mid-task, because functionally, that's exactly the situation the model is in.

Mistake 2: Tools that do too much, or too little

There's a tendency to either expose one giant run_query tool that takes raw SQL, or the opposite extreme: forty granular tools like get_user_name, get_user_email, get_user_created_at, each returning a single field.

Both are wrong, for different reasons.

The giant catch-all tool pushes all the reasoning burden onto the model at call time. It has to construct correct SQL, guess table names, and avoid destructive operations, all with no compile-time safety net. You've essentially given a probabilistic system write access to your database schema knowledge and hoped for the best.

The over-fragmented approach creates a different failure: the model now has to chain ten tool calls to answer one question, and every additional call is another chance for it to lose track of context, misorder steps, or time out. It also bloats your tool list, which matters more than people expect -- most MCP clients inject the full tool list into the model's context on every turn, so forty thin tools quietly eats your context budget before the actual conversation starts.

The right granularity is "one tool per task a user would actually describe in a sentence." Not "get email," but "get_user_profile" that returns the fields someone would reasonably need together. Not "run_sql," but "search_orders_by_status" with a constrained enum for status. This also gives you a natural place to enforce authorization and validation, which raw SQL access does not.

# Too broad -- pushes SQL construction and safety onto the model
@server.tool()
def run_query(sql: str) -> dict:
    return db.execute(sql)

# Too narrow -- forces chaining for anything useful
@server.tool()
def get_order_status(order_id: str) -> str: ...
@server.tool()
def get_order_total(order_id: str) -> float: ...
@server.tool()
def get_order_items(order_id: str) -> list: ...

# Right-sized -- one call answers one real question
@server.tool()
def get_order_summary(order_id: str) -> dict:
    """Returns status, total, line items, and shipping info
    for a single order in one call."""
    order = db.get_order(order_id)
    return {
        "status": order.status,
        "total": order.total,
        "items": order.items,
        "shipping": order.shipping_address,
    }

Mistake 3: No input validation because "the model will behave"

This is the mistake that turns into a security incident. Because tool arguments arrive from a model's generated JSON rather than a strict client SDK, it's tempting to assume the arguments will be well-formed since the schema "constrains" them. JSON Schema validation on an MCP tool call is typically advisory in practice -- many clients don't hard-enforce it, and even when they do, additionalProperties, string length, and semantic constraints (like "this ID must belong to the current tenant") are outside what JSON Schema checks.

We've seen MCP servers pass a file_path argument straight into a filesystem read with no path normalization, allowing an agent (intentionally prompted by a malicious user, or just confused) to read ../../etc/passwd-style paths. We've seen user_id arguments used directly in a query with no check that the ID belongs to the authenticated caller's tenant, effectively turning a scoped tool into an unscoped data-access backdoor.

Treat every argument coming into a tool handler exactly like you'd treat a public API request body: untrusted, until validated.

import os

BASE_DIR = "/var/app/user_uploads"

@server.tool()
def read_upload(user_id: str, filename: str) -> str:
    """Read a file the current user previously uploaded."""
    # Validate the caller can only touch their own scope
    if not is_authorized(current_session(), user_id):
        raise PermissionError("Not authorized for this user_id")

    # Reject path traversal before it ever touches the filesystem
    safe_name = os.path.basename(filename)
    full_path = os.path.normpath(os.path.join(BASE_DIR, user_id, safe_name))
    if not full_path.startswith(os.path.join(BASE_DIR, user_id)):
        raise ValueError("Invalid filename")

    with open(full_path, "r") as f:
        return f.read()

The rule of thumb: if a human attacker could type it into a form field, an agent can be steered into passing it as a tool argument, whether by a compromised prompt, a poisoned document the agent read earlier in its context, or plain model error. Validate accordingly.

Mistake 4: Overloading tool results with raw, unstructured data

A tool that returns a 50,000-token JSON blob because "the API just returns everything" is a mistake that's easy to make and expensive to live with. Every token in a tool result goes straight into the model's context window. Dump an entire database row with forty fields when the agent only needed three, and you've spent context budget on noise, made it more likely the model latches onto the wrong field, and slowed down every subsequent turn in that conversation.

This gets worse with list-returning tools. A search_documents tool that returns full document bodies for twenty results, instead of titles and snippets, can blow past a client's context limit on a single call and either truncate silently or error out.

The fix is to shape tool outputs the way you'd shape an API response for a mobile app on a slow connection: return only what's needed to either answer the question or decide the next step, and offer a way to fetch more detail on demand.

@server.tool()
def search_documents(query: str, limit: int = 10) -> dict:
    """Search documents. Returns titles and short snippets only.
    Use get_document(doc_id) to fetch full content for a specific result."""
    results = search_index.query(query, limit=limit)
    return {
        "results": [
            {
                "doc_id": r.id,
                "title": r.title,
                "snippet": r.text[:200],
            }
            for r in results
        ],
        "total_matches": search_index.count(query),
    }

Pair this with pagination for anything that can return more than a handful of items, and be explicit in the description about the default limit so the model doesn't assume it already has the full result set.

Mistake 5: Treating errors as an afterthought

When a tool call fails, what you return matters as much as what you return on success, arguably more. A raw stack trace or a bare {"error": "Internal Server Error"} gives the model nothing to work with. It can't tell whether to retry, whether the input was wrong, or whether it should give up and tell the user something failed.

We've watched agents get stuck in loops calling the same failing tool five times in a row because the error message gave no signal that retrying wouldn't help. We've also seen the opposite problem: an error that looks like a normal successful response (an empty list, a null field) gets silently treated as "no results" when it was actually "the downstream API timed out."

Structure errors so the model can reason about them the same way it reasons about successful data:

@server.tool()
def create_invoice(customer_id: str, amount: float) -> dict:
    """Create an invoice for a customer."""
    try:
        customer = billing_api.get_customer(customer_id)
    except CustomerNotFoundError:
        return {
            "success": False,
            "error_type": "not_found",
            "message": f"No customer found with id {customer_id}. "
                       f"Double-check the ID or use search_customers first.",
            "retryable": False,
        }
    except RateLimitError:
        return {
            "success": False,
            "error_type": "rate_limited",
            "message": "Billing API rate limit hit. Wait and retry.",
            "retryable": True,
        }

    invoice = billing_api.create_invoice(customer.id, amount)
    return {"success": True, "invoice_id": invoice.id, "status": invoice.status}

Notice the retryable flag and the actionable hint in message. That's not for a human reading logs later, it's for the model deciding its next move in the same turn.

Mistake 6: No authentication boundary between the agent and the backend

A pattern we keep running into: an MCP server that runs with a single, static, highly-privileged service credential, regardless of which end user is actually driving the agent. The server can hit the production database or the internal admin API using one shared API key, and there's no concept of "which human is this request actually on behalf of."

This works fine right up until the MCP server is exposed to more than one user, or the agent is compromised through a prompt injection in a document it read, or someone reuses the server for a lower-trust use case than it was designed for. At that point, every tool call runs with maximum privilege regardless of who's actually asking, and there's no way to scope it down.

The fix is to carry identity through the MCP session the same way you would in any multi-tenant API: authenticate the session (OAuth token, signed session cookie, whatever your stack uses), and pass that identity into every tool handler so authorization checks happen per-call, not once at server startup.

@server.tool()
def list_invoices(context: RequestContext) -> list:
    """List invoices for the currently authenticated account."""
    # context.session carries the identity established at connection time
    account_id = context.session.account_id
    if not account_id:
        raise PermissionError("No authenticated account for this session")

    return billing_api.list_invoices(account_id=account_id)

If your MCP server can't answer "which user is making this specific call" at the moment a tool executes, you don't have an authorization boundary, you have a shared root credential with a chat interface in front of it.

Mistake 7: Skipping idempotency on tools that cause side effects

Network calls fail. Clients retry. Agents sometimes call a tool twice because a previous response was ambiguous, or because the model reasoning process decided the first attempt "might not have worked." If your send_email or charge_customer or create_ticket tool isn't idempotent, one of these ordinary hiccups turns into a duplicate email, a double charge, or two tickets for the same issue.

This is a well-known pattern from webhook and payment API design, and it applies directly to MCP tools that mutate state: accept an idempotency key, and let the caller (or the server itself) generate one that's stable across retries of "the same" logical action.

@server.tool()
def create_support_ticket(
    title: str,
    description: str,
    idempotency_key: str,
) -> dict:
    """Create a support ticket. Pass a stable idempotency_key
    (e.g. derived from the conversation ID and a short hash of the
    title) so retries do not create duplicate tickets."""
    existing = ticket_store.find_by_idempotency_key(idempotency_key)
    if existing:
        return {"ticket_id": existing.id, "status": "already_created"}

    ticket = ticket_store.create(title=title, description=description,
                                  idempotency_key=idempotency_key)
    return {"ticket_id": ticket.id, "status": "created"}

For tools where you control key generation entirely server-side (rather than trusting the model to supply one), derive the key from something stable in the request, like a hash of the session ID plus the exact arguments, so an accidental duplicate call resolves to the same result instead of a new record.

Mistake 8: Ignoring how tool count and naming affect model tool-selection accuracy

As MCP servers grow organically, it's common to end up with thirty or more tools, several of which have overlapping purposes and near-identical names: get_user, get_user_info, fetch_user_details, user_lookup. Each one was added by a different engineer solving a slightly different problem, and none were removed.

The practical effect is that model tool-selection accuracy degrades. When two tools plausibly match a request, the model has to guess, and it will sometimes guess wrong, calling get_user_info when fetch_user_details had the field it actually needed. This isn't a hypothetical concern, it's one of the most reproducible failure modes in production MCP deployments, and it gets worse, not better, as you add more tools without pruning.

Two concrete habits fix most of this. First, do a periodic audit and consolidate overlapping tools into one well-described tool with optional parameters, rather than several near-duplicates. Second, adopt a consistent naming convention across the whole server (resource_action like invoice_create, invoice_list, invoice_void) so the model can predict tool names for actions it hasn't seen yet, instead of relying purely on the description text.

# Before: three overlapping tools, unclear which to use when
# get_user(id), fetch_user_details(id), user_lookup(query)

# After: one tool, clear parameter for the one real distinction that matters
@server.tool()
def user_get(identifier: str, by: str = "id") -> dict:
    """Look up a user. Set by='id' for internal user IDs (usr_...),
    or by='email' to look up via email address."""
    if by == "email":
        return db.get_user_by_email(identifier)
    return db.get_user_by_id(identifier)

Mistake 9: Shipping without testing the agent's actual behavior, only the API

The last mistake is process, not code: teams unit-test the underlying functions a tool calls, confirm the HTTP handler returns 200, and call it done. Nobody actually connects a real MCP client and watches an agent try to use the tools to complete a multi-step task.

This matters because the failure modes described above, vague descriptions, bad granularity, unhelpful errors, don't show up in a unit test. They only show up when a model is deciding, in context, which tool to call and how to interpret what came back. A tool can pass every unit test and still be nearly unusable by an agent because its description is misleading or its error format gives the model nothing to act on.

Before calling an MCP server production-ready, run it through actual multi-turn agent sessions covering your top five real use cases, not just "call the tool once and check the response." Watch for: does the agent pick the right tool on the first try, does it recover sensibly from an error, does it avoid redundant calls, does a long conversation stay within a reasonable context budget. Those are the metrics that predict whether the server will hold up once real users and real agents are pointed at it, and they're invisible from the API layer alone.

Building MCP servers that hold up in production

Every mistake in this list traces back to the same root cause: treating an MCP server like a traditional API when the actual consumer is a language model reasoning under uncertainty, not a strict typed client. Descriptions double as prompts. Schemas are advisory, not enforced. Errors need to be reasoned about, not just logged. Get those fundamentals right early, and most of the debugging pain that shows up months later in production simply doesn't happen.

If you want to go deeper on this, from designing tool schemas that models use correctly on the first try, to handling auth, idempotency, and context-window budgeting at scale, our course on Building & Integrating MCP Servers walks through the full lifecycle with hands-on projects, not just theory. It's built for engineers who are past the demo stage and need their MCP servers to survive contact with real agents and real users.