Wrapping REST APIs as MCP Tools
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.

The Problem: REST APIs Aren't Agent-Friendly
Example: Creating a Stripe invoice the REST way
- POST
/customersto create a customer - POST
/productsto create a product - POST
/pricesto create a price for that product - POST
/invoicesto create the invoice - POST
/invoiceitemsto add items to the invoice - POST
/invoices/:id/finalizeto finalize it - POST
/invoices/:id/sendto 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(notcreate_invoice+finalize_invoice+send_invoice)refund_payment(notget_payment+create_refund)schedule_meeting(notcheck_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 CRUD—
send_invoicebeatscreate+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
- Building Your First MCP Server – Tutorial on wrapping APIs
- FastMCP GitHub – Python library for building MCP servers
- Anthropic MCP Docs – Best practices
Most APIs can become great MCP tools with thoughtful wrapping. Think actions, not endpoints.