Skip to main content

Wrapping REST APIs as MCP Tools

· 8 min read
MCPBundles

Most APIs weren't built for AI agents. Stripe has 300+ endpoints. GitHub's API returns paginated results that change between requests. Slack rate-limits you after 20 calls per minute. None of them were designed for Claude to use directly.

We wrapped dozens of REST APIs into MCP tools and learned that a thin wrapper doesn't work. You need to rethink the interface completely—actions instead of CRUD, consolidated search instead of endless list endpoints, and server-side resilience for pagination and rate limits.

Here's what actually works.

Cartoon illustration of a person wrapping REST APIs as MCP tools, happy expression
We wrapped Stripe, GitHub, and Slack APIs into MCP tools. Here's what we learned about making REST APIs agent-friendly—what works and what doesn't.

The Problem: REST APIs Aren't Agent-Friendly

Example: Creating a Stripe invoice the REST way

  1. POST /customers to create a customer
  2. POST /products to create a product
  3. POST /prices to create a price for that product
  4. POST /invoices to create the invoice
  5. POST /invoiceitems to add items to the invoice
  6. POST /invoices/:id/finalize to finalize it
  7. POST /invoices/:id/send to email it to the customer

Seven API calls with dependency chains. If step 4 fails, you've created orphaned customers, products, and prices. Claude won't get this right consistently.

Better as an MCP tool:

@mcp.tool(description="Create and send an invoice to a customer")
async def create_and_send_invoice(
customer_email: str,
items: list[dict], # [{"name": "Service", "amount": 5000, "description": "Consulting"}]
due_days: int = 30
) -> dict:
# Handle all the orchestration server-side
customer = await get_or_create_customer(customer_email)
invoice = await create_invoice(customer.id, items, due_days)
await finalize_and_send_invoice(invoice.id)

return {
"invoice_id": invoice.id,
"customer_email": customer_email,
"amount": invoice.total,
"status": "sent"
}

One tool call. Transactional. If anything fails, we roll back. Claude calls this reliably.

Principle 1: Design Actions, Not CRUD Wrappers

Don't expose get_customer, create_customer, update_customer, delete_customer. Expose what users actually want to do:

  • send_invoice (not create_invoice + finalize_invoice + send_invoice)
  • refund_payment (not get_payment + create_refund)
  • schedule_meeting (not check_calendar + create_event + send_invites)

Example: GitHub API

GitHub has:

  • GET /repos/:owner/:repo/pulls (list PRs)
  • POST /repos/:owner/:repo/pulls (create PR)
  • POST /repos/:owner/:repo/pulls/:number/reviews (review PR)
  • PUT /repos/:owner/:repo/pulls/:number/merge (merge PR)
  • etc.

We wrapped it as:

@mcp.tool(description="Create a pull request from a branch")
async def create_pull_request(
repo: str, # "owner/repo"
branch: str,
title: str,
description: str = "",
reviewers: list[str] = []
) -> dict:
# One call does everything
pr = await github.create_pr(repo, branch, title, description)
if reviewers:
await github.request_reviewers(repo, pr.number, reviewers)

return {"pr_number": pr.number, "url": pr.html_url}

One action. Clear outcome.

Principle 2: Consolidate Discovery Into One Search Tool

Stripe has separate endpoints for:

  • List customers
  • List invoices
  • List products
  • List subscriptions
  • List payments
  • ...dozens more

Claude picks the wrong one constantly. "Find our Acme Corp subscription" would call list_customers, find nothing, then give up.

We built one unified search:

@mcp.tool(description="Search across all Stripe data")
async def search_stripe(
query: str,
type: Literal["all", "customer", "invoice", "subscription", "payment"] = "all",
limit: int = 20
) -> dict:
results = []

if type in ["all", "customer"]:
customers = await stripe.customers.search(query=f"email~'{query}' OR name~'{query}'", limit=limit)
results.extend([{"type": "customer", "id": c.id, "name": c.name or c.email} for c in customers])

if type in ["all", "subscription"]:
# Stripe doesn't have subscription search, so we list and filter
subs = await stripe.subscriptions.list(limit=100)
matching = [s for s in subs if query.lower() in (s.metadata.get("name", "").lower())]
results.extend([{"type": "subscription", "id": s.id, "status": s.status} for s in matching[:limit]])

# ... other types ...

return {"query": query, "results": results, "count": len(results)}

Now "Find Acme Corp" searches everything and returns relevant matches.

Principle 3: Handle Pagination Server-Side

APIs return pages of 100 results. If there are 847 customers, you need 9 requests. Don't make Claude do this.

Bad: exposing pagination to Claude

# ✗ Don't do this
@mcp.tool(description="List customers")
async def list_customers(page: int = 1) -> dict:
return await api.get(f"/customers?page={page}")

Claude has to figure out how many pages exist and call the tool multiple times.

Good: hiding pagination

# ✓ Do this
@mcp.tool(description="Search customers by email or name")
async def search_customers(query: str, max_results: int = 50) -> dict:
all_results = []
page = 1

while len(all_results) < max_results:
response = await api.get(f"/customers", params={
"query": query,
"page": page,
"per_page": 100
})

all_results.extend(response["data"])

if not response.get("has_more"):
break
page += 1

return {
"results": all_results[:max_results],
"total_found": len(all_results)
}

One call, all results (up to the limit).

Principle 4: Build In Rate Limit Handling

Most APIs rate-limit aggressively. Slack allows 20-50 requests per minute. GitHub has 5,000/hour. If Claude calls your tool 10 times quickly, you'll hit the limit and fail.

Add exponential backoff:

import asyncio
import random

async def call_with_retry(func, max_attempts=3):
"""Retry with exponential backoff on rate limits."""
for attempt in range(max_attempts):
try:
return await func()
except RateLimitError as e:
if attempt == max_attempts - 1:
raise

# Extract retry-after header if available
wait_time = e.retry_after if hasattr(e, 'retry_after') else (2 ** attempt)
jitter = random.uniform(0, 1)
total_wait = wait_time + jitter

logger.warning(f"Rate limited, waiting {total_wait:.1f}s")
await asyncio.sleep(total_wait)

raise Exception("Max retries exceeded")

@mcp.tool(description="Send a Slack message")
async def send_slack_message(channel: str, text: str) -> dict:
return await call_with_retry(
lambda: slack.chat_postMessage(channel=channel, text=text)
)

Now rate limits are invisible to Claude—they just work.

Principle 5: Return IDs, Not Full Objects

APIs return huge JSON objects. Stripe invoices have 40+ fields. GitHub PRs have 100+ fields. Don't send all of it back—Claude doesn't need it and it slows everything down.

Before (2.8KB response):

return await github.get_pull_request(repo, pr_number)
# Returns: {id, number, title, body, user{...}, head{...}, base{...},
# mergeable, merged, labels[], assignees[], ... 40 more fields}

After (180 bytes):

pr = await github.get_pull_request(repo, pr_number)
return {
"number": pr.number,
"title": pr.title,
"author": pr.user.login,
"status": "open" if pr.state == "open" else pr.merged and "merged" or "closed",
"url": pr.html_url
}

If Claude needs more details, it calls get_pull_request_details(pr_number).

Principle 6: Map API Errors to Helpful Messages

API errors are often cryptic. Turn them into something Claude and users can understand.

Before:

# Stripe returns: "No such customer: 'cus_invalid123'"
# Claude shows: "Error calling create_invoice"

After:

@mcp.tool(description="Create an invoice for a customer")
async def create_invoice(customer_id: str, items: list[dict]) -> dict:
try:
return await stripe.invoices.create(customer=customer_id, items=items)
except stripe.InvalidRequestError as e:
if "No such customer" in str(e):
raise MCPError(
code="CUSTOMER_NOT_FOUND",
message=f"Customer {customer_id} doesn't exist. Use search_customers to find the right ID."
)
elif "rate limit" in str(e).lower():
raise MCPError(
code="RATE_LIMIT",
message="Stripe rate limit exceeded. Try again in a few seconds."
)
else:
raise MCPError(code="STRIPE_ERROR", message=str(e))

Now Claude gets actionable errors: "Customer doesn't exist, here's how to fix it."

Real Example: Wrapping Slack

Slack's API has 220+ methods. We wrapped it into 6 MCP tools:

# Discovery
@mcp.tool(description="Search for channels, users, or messages")
async def search_slack(query: str, type: Literal["all", "channels", "users", "messages"] = "all") -> dict:
...

# Actions
@mcp.tool(description="Send a message to a channel")
async def send_message(channel: str, text: str, thread_ts: str | None = None) -> dict:
...

@mcp.tool(description="Create a new channel")
async def create_channel(name: str, is_private: bool = False) -> dict:
...

@mcp.tool(description="Invite users to a channel")
async def invite_to_channel(channel: str, users: list[str]) -> dict:
...

@mcp.tool(description="Upload a file to a channel")
async def upload_file(channel: str, file_url: str, title: str = "") -> dict:
...

@mcp.tool(description="Get recent messages from a channel")
async def get_channel_messages(channel: str, limit: int = 50) -> dict:
...

Six tools cover 90% of what users ask for. Clean, purposeful, reliable.

What We Learned

Start with 5-10 core actions, not 100 endpoints. Identify what users actually want to do and build those first.

Build one powerful search tool instead of 20 list endpoints. Consolidate discovery.

Hide complexity: pagination, rate limits, retries, error handling—all server-side.

Test with Claude directly. If Claude struggles to use a tool, the tool design is wrong. Simplify it.

Return minimal data. Summaries and IDs, not full objects. Let Claude fetch details if needed.

Handle auth carefully. Store tokens per-user, refresh them automatically, never log secrets.

Key Takeaways

  • REST APIs aren't agent-ready—thin wrappers don't work
  • Design actions, not CRUDsend_invoice beats create + finalize + send
  • One search tool beats 20 list endpoints
  • Hide pagination and rate limits in your server code
  • Return IDs and summaries, not 50-field objects
  • Map API errors to helpful, actionable messages
  • Start small with 5-10 core tools and expand based on usage

Resources

Most APIs can become great MCP tools with thoughtful wrapping. Think actions, not endpoints.