Wrapping an API as an MCP Server

You already have an API. Maybe it is your company’s internal service, maybe a third-party one like Stripe or Linear. The most common MCP server you will build is a thin layer that lets a model call that API on your behalf. This is the bread-and-butter server, and most teams get the shape of it wrong on the first try.

The mistake is to treat the server as a proxy: one tool per endpoint, arguments copied straight from the API docs. That produces a server the model cannot use well. This article is about the decisions that separate a wrapper that works from one that connects but frustrates every call.

The pattern: a thin translation layer

An API-wrapping server sits between the model and an HTTP API. When the model calls a tool, your server makes one or more HTTP requests, shapes the result, and hands back text the model can read. That is the whole job.

flowchart LR
    M["Model"] -->|"tools/call"| S["Your MCP server"]
    S -->|"HTTP + API key"| API["REST API"]
    API -->|"JSON"| S
    S -->|"text content"| M

The value your server adds is not the plumbing; anyone can forward a request. The value is in three things: shaping tools around tasks instead of endpoints, holding the credentials so the model never touches them, and turning raw HTTP responses into something small and readable. Get those right and the server feels effortless.

If you are fuzzy on how tools and their schemas are defined, article 04: Tools, Resources, and Prompts covers the primitives this article builds on.

Design tools to be task-shaped, not a mirror of the API

Here is the single most important decision. Your API might have twenty endpoints. Your MCP server should not have twenty tools.

An API is designed for a programmer who reads the docs, chains calls, and holds state between them. A model gets a list of tools and a user request, and has to pick and fill one in a single shot. Those are different consumers. A tool named GET /customers with fifteen query parameters is useless to a model. A tool named find_customer that takes an email or a name is obvious.

Think in terms of the task the user wants done, then build the tool around it, even if that means one tool fans out into several API calls internally.

flowchart TB
    subgraph bad ["1:1 mirror, hard to use"]
        E1["list_customers"]
        E2["get_customer_by_id"]
        E3["search_customers"]
        E4["get_customer_orders"]
    end
    subgraph good ["task-shaped, easy to use"]
        T1["find_customer"]
        T2["get_recent_orders"]
    end

A find_customer tool can search by email, fall back to a name search, and pull the customer’s ID in one call. The model does not need to know your API splits that across three endpoints. It asked a question; it got an answer.

The rule of thumb: name tools after the job, keep the argument list short, and hide the API’s internal structure. If a human would describe the action in a few plain words, that is your tool.

Why fewer, task-shaped tools beat a full mirror of the API?

Every tool you expose is described to the model, and those descriptions cost tokens on every request. A 20-tool server burns context before the model has done anything.

More tools also means more chances for the model to pick the wrong one or misfill an argument. Each tool is a decision it has to get right. Fewer, well-named tools shrink the decision space and raise the odds of a correct first call. You are optimizing for the model’s judgment, not for API completeness.

Auth: the server holds the keys, the model never sees them

Your server needs credentials to call the API. The model must never receive them, log them, or pass them as a tool argument. This is not optional. Anything the model can read can end up in its output.

Read credentials from environment variables at startup. Never hardcode a key in the source, and never accept one as a tool parameter.

import os
import requests

API_KEY = os.environ["ACME_API_KEY"]  # fail loud at startup if missing
BASE_URL = "https://api.acme.example/v1"

SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {API_KEY}"})

The SESSION object attaches the key to every request. The model calls find_customer("jane@example.com"); it never sees the header, the token, or the base URL. Credentials live in the process environment, set by whoever runs the server: your shell or the host config on a local stdio server, a secret manager on a hosted HTTP server.

One more rule: never echo a credential back in an error. A 401 handler that returns “auth failed for key sk-abc123” has just leaked the key into the model’s context. Return “authentication failed, check server credentials” and log the detail to stderr.

Error handling: return a message the model can act on

The API will fail. A customer will not exist, a rate limit will trip, the service will time out. Do not let a raw exception or a stack trace bubble up to the model. It cannot do anything with a traceback, and the noise eats tokens.

Catch HTTP errors and translate them into one short, plain sentence that says what happened and, where possible, what to do next.

def api_get(path: str, params: dict | None = None) -> dict:
    try:
        resp = SESSION.get(f"{BASE_URL}{path}", params=params, timeout=10)
        resp.raise_for_status()
        return resp.json()
    except requests.exceptions.HTTPError:
        code = resp.status_code
        if code == 404:
            raise ValueError("Not found. Check the identifier and try again.")
        if code == 429:
            raise ValueError("Rate limited. Wait a moment before retrying.")
        raise ValueError(f"API returned {code}. The request could not be completed.")
    except requests.exceptions.Timeout:
        raise ValueError("The API timed out. It may be slow or down; try again shortly.")

The distinction that matters: a 404 is recoverable (the model can ask for a better identifier), a 429 is recoverable (wait and retry), a 500 usually is not. Say which, briefly. A good error message is one the model can turn into a sensible next action or a clear explanation to the user.

Practical concerns: pagination, rate limits, and trimming

Three things bite every API wrapper in production.

Pagination. APIs return long lists across pages. A model does not want 400 customers; it wants the one it asked about, or the first handful. Handle paging inside the tool and cap what you return: if a search matches 400 rows, return the top 5 and a note that more exist. Do not loop through every page and dump the lot into context.

Rate limits. You share a quota with everything else calling that API. Respect 429 and Retry-After, back off, and consider a short client-side cache for reads that repeat. The model has no concept of your quota, so the server has to protect it.

Trimming large responses. This is where you save the most tokens. API responses are fat: internal IDs, timestamps, audit fields, nested objects the model will never use. Pick out the fields that matter and drop the rest before building the response.

def find_customer(query: str) -> str:
    data = api_get("/customers/search", params={"q": query, "limit": 5})
    results = data.get("results", [])
    if not results:
        return f"No customer found matching '{query}'."

    lines = []
    for c in results:  # keep only fields the model needs
        lines.append(
            f"- {c['name']} (id: {c['id']}), "
            f"email: {c['email']}, status: {c['status']}"
        )
    trailer = "" if len(results) < 5 else "\n(More matches exist; refine the query.)"
    return "Matching customers:\n" + "\n".join(lines) + trailer

Notice what this returns: plain, structured text, not raw JSON. A trimmed line per customer costs a fraction of the tokens the full API object would. You are the filter between a verbose API and a context window that fills up fast.

Why return text instead of the raw JSON the API gave you?

The short answer: JSON is expensive and noisy, and the model does not need most of it.

A raw customer object might be 40 fields; the model needs 4. Returning a few labeled lines costs a fraction of the tokens and reads more clearly, which also improves the answer. Return structured JSON when a downstream step truly needs to parse it, but for most read tools compact text is the better default. Return what the model needs to act, not everything the API knows.

Common beginner mistakes

  • Mirroring every endpoint: one tool per route gives the model twenty confusing options instead of two obvious ones.
  • Accepting the API key as an argument: credentials come from the environment; a tool parameter puts the key in the model’s context.
  • Leaking secrets in errors: never put a token or key into a message the model can read; log the detail to stderr.
  • Returning raw stack traces: the model cannot act on a traceback; catch it and return one plain sentence.
  • Dumping full responses: returning the entire JSON payload wastes tokens and buries the answer. Trim to the fields that matter.
  • Ignoring pagination: looping every page into context can blow the window on a single call. Cap and summarize.

Questions you will face in production

“How many tools should my API wrapper expose?” As few as cover the real tasks. Start by listing the jobs users actually ask for, not the endpoints you have. Most internal-API wrappers land at three to six tools. If you catch yourself adding a tool per endpoint, stop and ask what task the user is trying to do; usually several endpoints collapse into one tool.

“The API returns huge objects. Should I just pass them through?” No. Trim before you return. Decide which fields the model needs for the task and drop the rest. If a later step genuinely needs a raw field, add it back deliberately. Passing the whole object through is the fastest way to fill the context window and slow every response.

“Where do I put the API key for a hosted HTTP server?” In a secret manager or the platform’s environment config, read into an env var at startup, exactly as on a local server. The code does not change; only where the value comes from does. Never commit it, never send it to the model, never return it in an error.

What to remember

  • An API wrapper is a translation layer: shape tools, hold credentials, trim responses.
  • Design tools around tasks, not endpoints; a few good tools beat a full API mirror.
  • Read API keys from the environment; the server holds them, the model never sees them.
  • Catch HTTP errors and return one short, actionable sentence, not a stack trace.
  • Handle pagination and rate limits inside the server; the model knows nothing about your quota.
  • Return trimmed, structured text with only the fields that matter, to save tokens and improve answers.

What to study next

You can now wrap an API into a working server. The next step is the polish that makes it reliable: clear tool descriptions, sensible defaults, timeouts, testing, and the conventions that keep a server maintainable. That is the subject of article 06: MCP Server Best Practices, which turns the patterns here into habits you apply to every server.

Further reading

Where this article comes from. This is a synthesis of common practice in AI engineering as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.


Auto-marks when you reach the end. Click to toggle.

If this helped, buy me a coffee

Everything is free. Tips keep me writing the rest.

Buy me a coffee →