Wrapping Internal REST APIs as MCP Tools

13 min read Module 5 of 10 Topic 13 of 30

What you'll learn

  • Avoid the naive trap of mapping every REST endpoint 1:1 to an MCP tool
  • Design tool granularity around agent tasks rather than API surface area
  • Translate REST-level errors (4xx/5xx) into structured, actionable MCP tool errors
  • Decide when to normalize a legacy API's inconsistencies at the MCP layer versus exposing them as-is
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The most common mistake when exposing an internal REST API through MCP is a mechanical 1:1 mapping: one tool per endpoint. This preserves the API’s internal structure rather than designing around what an agent actually needs to accomplish, and it forces the model to chain many low-level calls to do anything useful. This lesson works through tool granularity, error translation, and a question that comes up constantly with older internal APIs: how much of the underlying system’s rough edges should the MCP layer smooth over.

Design Tools Around Tasks, Not Endpoints

Consider a customer service REST API with GET /users/{id}, GET /users/{id}/orders, GET /users/{id}/addresses, and GET /users/{id}/payment-methods. Exposing four separate tools means the model must make four sequential calls (and reason about the right order) just to answer “give me this customer’s profile.” A better design consolidates that into one task-shaped tool.

flowchart LR
    subgraph naive["Naive: 1:1 endpoint mapping"]
        M1["Model"] --> E1["get_user"]
        M1 --> E2["get_user_orders"]
        M1 --> E3["get_user_addresses"]
        M1 --> E4["get_user_payment_methods"]
    end

    subgraph better["Task-oriented tool"]
        M2["Model"] --> T["get_customer_profile"]
        T --> E1b["GET /users/{id}"]
        T --> E2b["GET /users/{id}/orders"]
        T --> E3b["GET /users/{id}/addresses"]
    end

    style M1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style M2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style T fill:#f0fdf9,stroke:#0D9488,color:#0F172A
class CustomerProfile(BaseModel):
    user: dict
    recent_orders: list[dict]
    primary_address: dict | None

@mcp.tool(name="get_customer_profile")
async def get_customer_profile(user_id: str) -> CustomerProfile:
    """
    Fetch a consolidated customer profile: basic info, the 5 most recent
    orders, and primary shipping address. Use this instead of separate
    lookups whenever a task needs a general view of a customer.
    """
    async with httpx.AsyncClient(base_url=INTERNAL_API_BASE) as client:
        user_resp, orders_resp, addr_resp = await asyncio.gather(
            client.get(f"/users/{user_id}"),
            client.get(f"/users/{user_id}/orders", params={"limit": 5}),
            client.get(f"/users/{user_id}/addresses", params={"type": "primary"}),
        )
    return CustomerProfile(
        user=user_resp.json(),
        recent_orders=orders_resp.json()["orders"],
        primary_address=(addr_resp.json() or {}).get("address"),
    )

Keep a small number of narrow, single-purpose tools alongside the consolidated one (e.g. update_user_address for a targeted write) rather than trying to force every operation through a single “do everything” tool, granularity should match the shape of real tasks, not always favor maximal consolidation. A useful heuristic: if a real support agent or salesperson, doing this task by hand, would naturally think of it as “one thing” (checking a customer’s overall status), consolidate; if they would naturally think of it as several distinct actions with independent triggers (looking someone up versus updating their address, which happens far less often and for a different reason), keep them separate.

Handling Partial Failure Across a Consolidated Call

The consolidation above introduces a new failure mode a single-endpoint tool never had: what happens when two of the three underlying calls succeed but one fails? Silently returning a profile with a missing field, with no indication anything went wrong, risks the model treating an absent address as “this customer has no address” rather than “the address lookup failed.” The fix is to surface partial failure explicitly, in the same spirit as the batch partial-success pattern from Lesson 6.

class CustomerProfileResult(BaseModel):
    user: dict
    recent_orders: list[dict] | None
    primary_address: dict | None
    warnings: list[str] = []

@mcp.tool(name="get_customer_profile")
async def get_customer_profile(user_id: str) -> CustomerProfileResult:
    """Consolidated customer profile. Reports which sub-lookups failed, if any, via `warnings`."""
    async with httpx.AsyncClient(base_url=INTERNAL_API_BASE) as client:
        user_resp = await client.get(f"/users/{user_id}")
        user_resp.raise_for_status()  # This one is load-bearing; fail the whole tool if it fails

        warnings = []
        try:
            orders_resp = await client.get(f"/users/{user_id}/orders", params={"limit": 5})
            orders_resp.raise_for_status()
            recent_orders = orders_resp.json()["orders"]
        except httpx.HTTPError:
            recent_orders = None
            warnings.append("Could not fetch recent orders, order service may be degraded")

        try:
            addr_resp = await client.get(f"/users/{user_id}/addresses", params={"type": "primary"})
            addr_resp.raise_for_status()
            primary_address = (addr_resp.json() or {}).get("address")
        except httpx.HTTPError:
            primary_address = None
            warnings.append("Could not fetch primary address, address service may be degraded")

    return CustomerProfileResult(
        user=user_resp.json(), recent_orders=recent_orders,
        primary_address=primary_address, warnings=warnings,
    )

Note the distinction between the core user_resp call (whose failure should fail the entire tool, there is no meaningful “customer profile” without basic user data) and the two secondary calls (whose individual failure degrades the result but should not block the whole response). Deciding which calls are load-bearing versus best-effort is a design judgment specific to each tool, not a mechanical rule, but making that judgment explicit in code, rather than letting any single failed await silently propagate as an unhandled exception, is what keeps consolidated tools trustworthy.

Translating REST Errors into MCP Errors

Raw HTTP status codes are meaningless to a model unless translated into actionable, structured tool errors.

@mcp.tool(name="update_user_address")
async def update_user_address(user_id: str, address: AddressInput) -> dict:
    """Update a customer's primary shipping address."""
    try:
        resp = await internal_api_client.put(f"/users/{user_id}/addresses/primary", json=address.model_dump())
        resp.raise_for_status()
        return {"success": True, "address": resp.json()}
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return {"isError": True, "retryable": False, "message": f"No user found with id '{user_id}'"}
        if e.response.status_code == 429:
            retry_after = e.response.headers.get("Retry-After", "60")
            return {"isError": True, "retryable": True, "message": f"Rate limited, retry after {retry_after}s"}
        if e.response.status_code == 422:
            return {"isError": True, "retryable": False, "message": f"Invalid address: {e.response.json().get('detail')}"}
        raise  # Unexpected 5xx: let it surface as a genuine execution failure

Normalizing Legacy API Inconsistency at the Wrapping Layer

Internal REST APIs, especially older ones accumulated over years across different subsystems and teams, are rarely internally consistent: date formats vary, error response shapes vary, pagination conventions vary between endpoints written by different people at different times. A natural question when wrapping such an API is whether the MCP tool should faithfully mirror these inconsistencies or paper over them. The strong recommendation is to normalize at the wrapping layer: present one consistent date format, one consistent error shape, and one consistent pagination convention across every tool, regardless of which legacy quirk produced the underlying raw response.

from datetime import datetime

def normalize_date(raw_value) -> str:
    """Every tool in this server returns ISO 8601 dates, regardless of
    whether the underlying legacy endpoint returned a Unix timestamp,
    an ISO string, or a locale-formatted date string."""
    if isinstance(raw_value, (int, float)):
        return datetime.utcfromtimestamp(raw_value).isoformat() + "Z"
    if isinstance(raw_value, str) and "/" in raw_value:  # legacy MM/DD/YYYY subsystem
        return datetime.strptime(raw_value, "%m/%d/%Y").isoformat() + "Z"
    return raw_value  # already ISO 8601

This is one of the highest-value, lowest-risk changes an MCP wrapping layer can make: it costs nothing to the underlying system (no changes to the legacy API itself are required) and it removes an entire class of model confusion where output that looks superficially similar (a date, an error) actually varies unpredictably in format depending on which internal code path happened to generate it. The next lesson applies this same design discipline, granularity, partial-failure handling, and normalization, to a specific, high-value enterprise category: database and data warehouse access.

Knowledge Check

3 questions to test your understanding

1 An internal REST API has 40 fine-grained endpoints (get_user, get_user_orders, get_user_addresses, get_user_payment_methods, ...). What is the recommended approach for exposing this via MCP?

2 A wrapped REST endpoint returns HTTP 429 (rate limited) when a tool is called. What should the MCP tool return to the agent?

3 An internal REST API from 2015 uses inconsistent conventions: some endpoints return dates as Unix timestamps, others as ISO 8601 strings, and error responses have three different shapes depending on which subsystem generated them. When wrapping this API as MCP tools, should these inconsistencies be preserved in the tool's output or normalized?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan