180 lines
8.1 KiB
TypeScript
180 lines
8.1 KiB
TypeScript
|
|
import { JsonLd } from '@/components/json-ld';
|
|||
|
|
import { StaticCodeBlock } from '@/components/static-code-block';
|
|||
|
|
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
|||
|
|
import Link from 'next/link';
|
|||
|
|
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
|
|||
|
|
|
|||
|
|
const PATH = '/guides/rest-api-to-mcp-server';
|
|||
|
|
const TITLE = 'Wrap any REST API as an MCP server';
|
|||
|
|
const DESCRIPTION =
|
|||
|
|
'How to turn a REST API into an MCP server your AI clients can use: designing the tool surface (fewer, better tools beat 1:1 endpoint mapping), handling auth headers with encrypted secrets, and respecting upstream rate limits.';
|
|||
|
|
|
|||
|
|
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
|||
|
|
|
|||
|
|
const BAD_PROMPT = `Create an MCP server for our API.
|
|||
|
|
Endpoints: GET /users, GET /users/:id, POST /users, PUT /users/:id,
|
|||
|
|
DELETE /users/:id, GET /orders, GET /orders/:id, POST /orders,
|
|||
|
|
GET /invoices, GET /invoices/:id, POST /invoices/:id/send ...`;
|
|||
|
|
|
|||
|
|
const GOOD_PROMPT = `Create an MCP server for our billing API (https://api.example.com).
|
|||
|
|
Tools:
|
|||
|
|
- find_customer: search customers by name or email, return id, plan, status
|
|||
|
|
- get_open_invoices: list unpaid invoices for a customer id, with amounts
|
|||
|
|
- send_invoice_reminder: send the dunning email for one invoice id
|
|||
|
|
Auth: BILLING_API_KEY sent as "Authorization: Bearer" header.
|
|||
|
|
Read-heavy; send_invoice_reminder is the only write action.`;
|
|||
|
|
|
|||
|
|
export default function Page() {
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<JsonLd
|
|||
|
|
data={articleJsonLd({
|
|||
|
|
title: TITLE,
|
|||
|
|
description: DESCRIPTION,
|
|||
|
|
path: PATH,
|
|||
|
|
datePublished: '2026-07-08',
|
|||
|
|
authorName: 'Marco Sadjadi',
|
|||
|
|
wordCount: 1400,
|
|||
|
|
})}
|
|||
|
|
/>
|
|||
|
|
<JsonLd
|
|||
|
|
data={breadcrumbJsonLd([
|
|||
|
|
{ name: 'Home', path: '/' },
|
|||
|
|
{ name: 'Guides', path: '/guides' },
|
|||
|
|
{ name: TITLE, path: PATH },
|
|||
|
|
])}
|
|||
|
|
/>
|
|||
|
|
<ArticleShell
|
|||
|
|
title={TITLE}
|
|||
|
|
subtitle="Every REST API is one wrapper away from being usable by Claude, Cursor or ChatGPT. The wrapper is an MCP server — and the difference between a useful one and a frustrating one is decided before any code exists, in how you design the tool surface."
|
|||
|
|
updated="July 2026"
|
|||
|
|
>
|
|||
|
|
<H2>The core mistake: 1:1 endpoint mapping</H2>
|
|||
|
|
<P>
|
|||
|
|
The obvious approach — one MCP tool per REST endpoint — produces a bad server. An AI
|
|||
|
|
assistant choosing between 30 near-identical tools burns context on the choice, chains
|
|||
|
|
three calls where one would do, and picks wrong often enough to erode trust. The
|
|||
|
|
assistant is not a REST client; it does not want your resource model, it wants{' '}
|
|||
|
|
<Strong>tasks</Strong>.
|
|||
|
|
</P>
|
|||
|
|
<StaticCodeBlock code={BAD_PROMPT} label="what not to do" />
|
|||
|
|
<P>
|
|||
|
|
Design the tool surface the way you would design CLI commands for a colleague:
|
|||
|
|
task-shaped, few, with the joins already done.
|
|||
|
|
</P>
|
|||
|
|
<StaticCodeBlock code={GOOD_PROMPT} label="the same API, task-shaped" />
|
|||
|
|
<UL>
|
|||
|
|
<li>
|
|||
|
|
<Strong>3–7 tools</Strong> is the sweet spot for a single-purpose server. More than ten
|
|||
|
|
and selection quality drops.
|
|||
|
|
</li>
|
|||
|
|
<li>
|
|||
|
|
<Strong>Fold lookups into the tool.</Strong> If sending a reminder needs a customer id,
|
|||
|
|
let <Strong>find_customer</Strong> exist — but do not expose the four intermediate
|
|||
|
|
endpoints the API needs internally.
|
|||
|
|
</li>
|
|||
|
|
<li>
|
|||
|
|
<Strong>Return shaped results, not raw payloads.</Strong> “id, plan, status” beats the
|
|||
|
|
full 80-field customer object; the model reads every byte you return.
|
|||
|
|
</li>
|
|||
|
|
<li>
|
|||
|
|
<Strong>Separate reads from writes</Strong> and say so in the description — clients
|
|||
|
|
like ChatGPT restrict write tools on individual plans (
|
|||
|
|
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
|
|||
|
|
details here
|
|||
|
|
</Link>
|
|||
|
|
), and a clean read/write split keeps the read tools usable everywhere.
|
|||
|
|
</li>
|
|||
|
|
</UL>
|
|||
|
|
|
|||
|
|
<H2>Auth: the API key never goes in the prompt</H2>
|
|||
|
|
<P>
|
|||
|
|
The wrapper needs your API's credential, and there is exactly one right place for it: an{' '}
|
|||
|
|
<Strong>encrypted secret</Strong>, referenced by name. In the prompt above,{' '}
|
|||
|
|
<Strong>BILLING_API_KEY</Strong> is a name, not a value. You provide the value separately
|
|||
|
|
in the dashboard; it is encrypted with AES-256-GCM at rest and injected into the server's
|
|||
|
|
container as an environment variable at runtime — never logged, never echoed back, never
|
|||
|
|
part of the generated source.
|
|||
|
|
</P>
|
|||
|
|
<P>
|
|||
|
|
The second auth layer is between the AI client and your MCP server: every deployed server
|
|||
|
|
sits behind OAuth 2.1 (PKCE, Dynamic Client Registration, Resource Indicators), so your
|
|||
|
|
wrapped API is not one guessable URL away from the public internet. How that handshake
|
|||
|
|
works:{' '}
|
|||
|
|
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
|
|||
|
|
OAuth docs
|
|||
|
|
</Link>
|
|||
|
|
.
|
|||
|
|
</P>
|
|||
|
|
<Note>
|
|||
|
|
Start read-only. A read-only wrapper cannot damage anything while you learn how the
|
|||
|
|
assistant actually uses the tools; add the one or two write actions after a week of
|
|||
|
|
watching the call logs.
|
|||
|
|
</Note>
|
|||
|
|
|
|||
|
|
<H2>Rate limits: yours and theirs</H2>
|
|||
|
|
<Table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>Layer</th>
|
|||
|
|
<th>What limits it</th>
|
|||
|
|
<th>What to do</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr>
|
|||
|
|
<td>Client → MCP server</td>
|
|||
|
|
<td>OAuth gate before your container; plan quotas (free tier: 100k calls/mo)</td>
|
|||
|
|
<td>Nothing — enforced for you.</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>MCP server → upstream API</td>
|
|||
|
|
<td>The upstream's own rate limits</td>
|
|||
|
|
<td>
|
|||
|
|
Tell the generator: “respect a limit of N req/s; on 429, back off and surface the
|
|||
|
|
error”. Shaped tools help here too — one task call instead of five endpoint calls.
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Model behavior</td>
|
|||
|
|
<td>Assistants retry failed calls</td>
|
|||
|
|
<td>
|
|||
|
|
Return clear error messages (“rate limited, retry in 30s”) — models read them and
|
|||
|
|
actually wait.
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</Table>
|
|||
|
|
|
|||
|
|
<H2>From prompt to installed tool, end to end</H2>
|
|||
|
|
<P>
|
|||
|
|
With the prompt written, the rest is mechanical:{' '}
|
|||
|
|
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
|
|||
|
|
generation, static checks, container build and deploy
|
|||
|
|
</Link>{' '}
|
|||
|
|
take 45–90 seconds, and the dashboard gives you install snippets for{' '}
|
|||
|
|
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
|
|||
|
|
Claude Desktop
|
|||
|
|
</Link>
|
|||
|
|
, Cursor and ChatGPT. The generated TypeScript is exportable — if your wrapper outgrows
|
|||
|
|
prompt-editing (complex retries, multi-step workflows), take the source and continue by
|
|||
|
|
hand with the boilerplate already written.
|
|||
|
|
</P>
|
|||
|
|
<P>
|
|||
|
|
If your API resembles something common — Notion, GitHub, Stripe, PostgreSQL — check{' '}
|
|||
|
|
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
|||
|
|
the templates
|
|||
|
|
</Link>{' '}
|
|||
|
|
first: forking a working server and swapping in your credential is faster than writing
|
|||
|
|
any prompt. Otherwise, the{' '}
|
|||
|
|
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
|||
|
|
free tier
|
|||
|
|
</Link>{' '}
|
|||
|
|
covers one server — enough to wrap the API you use most and find out what your assistant
|
|||
|
|
does with it.
|
|||
|
|
</P>
|
|||
|
|
</ArticleShell>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|