Wrap Any REST API as an MCP Server: The Universal Pattern
If you already have a REST API — your own product API, a third-party service you use, or an internal microservice — you are 80% of the way to having an MCP server. The pattern for wrapping a REST API as an MCP server is generic, well-trodden, and worth knowing well, because it is the single most common way new MCP servers come into existence.
This article shows the universal pattern using a real, free, no-auth API (JSONPlaceholder) so you can run every example as-is. Then we walk through the design choices that make the difference between a technically correct wrapper and one an LLM can actually use well.
The mental model #
A REST API exposes endpoints. An MCP server exposes tools. The wrapping job is to map one to the other thoughtfully.
Naive mapping (works, but suboptimal):
| REST endpoint | MCP tool |
|---|---|
GET /posts |
list_posts |
GET /posts/{id} |
get_post |
POST /posts |
create_post |
PUT /posts/{id} |
update_post |
DELETE /posts/{id} |
delete_post |
This works, but it gives the LLM the exact surface of the REST API — which is shaped for code, not for natural language. A better wrapper consolidates, hides, and adds LLM-friendly shortcuts.
We will build both and compare.
Setup #
mkdir jsonplaceholder-mcp
cd jsonplaceholder-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
Add "type": "module" to package.json.
The thin wrapper (one tool per endpoint) #
Create server.js:
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const API = 'https://jsonplaceholder.typicode.com';
async function api(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...(init.headers || {}) },
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
const server = new McpServer({ name: 'jsonplaceholder', version: '1.0.0' });
server.tool('list_posts', 'List all posts (returns 100 entries).', {}, async () => {
const posts = await api('/posts');
return { content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }] };
});
server.tool('get_post', 'Fetch a single post by ID.',
{ id: z.number().int().positive() },
async ({ id }) => {
const post = await api(`/posts/${id}`);
return { content: [{ type: 'text', text: JSON.stringify(post, null, 2) }] };
}
);
server.tool('create_post', 'Create a new post.',
{ title: z.string(), body: z.string(), userId: z.number().int() },
async (args) => {
const post = await api('/posts', { method: 'POST', body: JSON.stringify(args) });
return { content: [{ type: 'text', text: `Created post #${post.id}` }] };
}
);
await server.connect(new StdioServerTransport());
That is a perfectly valid MCP server. Wire it into Claude Desktop, ask "show me post 5," and it works.
But now look at the output of list_posts — 100 JSON objects with hundreds of fields. The LLM has to wade through ~50,000 tokens of noise to answer "what was the title of the most recent post?"
The thoughtful wrapper (LLM-friendly tools) #
A better wrapper does four things:
- Trims the response to what the LLM actually needs.
- Adds parameters that real users want (limit, search, by-user).
- Returns text the LLM can read directly, not raw JSON the LLM has to re-parse.
- Composes related endpoints when it makes sense (e.g., one tool that returns a post and its comments).
Here is the same API as a thoughtful wrapper:
server.tool('search_posts',
'Search posts by keyword (matches title and body). Returns up to 10 results with summary info.',
{
query: z.string().describe('Keyword to match'),
limit: z.number().int().min(1).max(50).default(10),
},
async ({ query, limit }) => {
const all = await api('/posts');
const q = query.toLowerCase();
const hits = all
.filter(p => p.title.toLowerCase().includes(q) || p.body.toLowerCase().includes(q))
.slice(0, limit);
if (!hits.length) {
return { content: [{ type: 'text', text: `No posts found matching "${query}".` }] };
}
const lines = hits.map(p => `#${p.id} "${p.title}" (by user ${p.userId})`);
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
server.tool('get_post_with_comments',
'Fetch a post and all its comments in one call.',
{ id: z.number().int().positive() },
async ({ id }) => {
const [post, comments] = await Promise.all([
api(`/posts/${id}`),
api(`/posts/${id}/comments`),
]);
const cText = comments.map(c => ` • ${c.email}: ${c.body}`).join('\n');
return {
content: [{
type: 'text',
text: `Post #${post.id}: ${post.title}\n\n${post.body}\n\n--- Comments (${comments.length}) ---\n${cText}`,
}],
};
}
);
server.tool('posts_by_user',
"List all posts by a specific user.",
{ userId: z.number().int().positive() },
async ({ userId }) => {
const posts = await api(`/users/${userId}/posts`);
const lines = posts.map(p => `#${p.id} ${p.title}`);
return { content: [{ type: 'text', text: lines.join('\n') || 'No posts.' }] };
}
);
Notice the differences:
search_postsis something the LLM actually wants. The raw API has no search; we synthesized it client-side. The LLM never has to scan 100 records itself.get_post_with_commentscollapses two endpoints into one tool. Saves a round-trip and reads naturally as a single "give me the post" intent.- Output is preformatted text, not raw JSON. The LLM does not have to parse it before answering the user.
- Limits and defaults make response sizes predictable.
Authentication patterns #
JSONPlaceholder needs no auth, but most real APIs do. Three common patterns:
Static API key (simplest) #
const API_KEY = process.env.API_KEY;
if (!API_KEY) throw new Error('Set API_KEY environment variable.');
async function api(path, init = {}) {
return fetch(`${API}${path}`, {
...init,
headers: { Authorization: `Bearer ${API_KEY}`, ...init.headers },
});
}
The user puts their key in their claude_desktop_config.json server entry as an env property:
{
"mcpServers": {
"my-api": {
"command": "npx",
"args": ["-y", "my-api-mcp"],
"env": { "API_KEY": "sk_live_..." }
}
}
}
Per-user OAuth (more complex) #
For APIs that need per-user authorization (think: "my GitHub issues" not "the GitHub public API"), MCP supports OAuth flows via the Streamable HTTP transport. Full coverage is its own article, but the gist is: the server registers OAuth metadata, the host opens a browser, the user authorizes, and the host stores the resulting token for subsequent calls.
Personal access tokens (PATs) #
For developer-tools APIs (GitHub, GitLab, Linear), the simplest path is to ask the user for a PAT once, treat it like an API key, and document the scopes required. Less elegant than OAuth, but works everywhere.
Pagination #
APIs that paginate large result sets need careful handling. Two patterns:
Pattern A: Expose the cursor to the LLM.
server.tool('list_users',
'List users with pagination. Pass cursor from previous response to continue.',
{ cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(20) },
async ({ cursor, limit }) => {
const url = `/users?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`;
const data = await api(url);
return {
content: [{
type: 'text',
text: `${data.items.map(u => u.name).join('\n')}\n\nNext cursor: ${data.nextCursor || '(none)'}`,
}],
};
}
);
Pattern B: Auto-paginate up to a sensible cap.
server.tool('list_all_users',
'List up to the first 500 users.',
{},
async () => {
const all = [];
let cursor;
while (all.length < 500) {
const data = await api(`/users?limit=100${cursor ? `&cursor=${cursor}` : ''}`);
all.push(...data.items);
if (!data.nextCursor) break;
cursor = data.nextCursor;
}
return { content: [{ type: 'text', text: all.map(u => u.name).join('\n') }] };
}
);
Pattern B is more LLM-friendly (no cursor juggling), but you must enforce a cap to prevent runaway calls.
Error normalization #
A real REST API can fail in dozens of ways — 4xx, 5xx, network timeouts, rate limits. Translate them into responses the LLM can act on:
async function safeApi(path, init = {}) {
try {
const res = await fetch(`${API}${path}`, init);
if (res.status === 429) return { error: 'Rate limit hit. Please retry in a minute.' };
if (res.status === 404) return { error: 'Not found.' };
if (!res.ok) return { error: `API error ${res.status}: ${await res.text()}` };
return { data: await res.json() };
} catch (err) {
return { error: `Network error: ${err.message}` };
}
}
server.tool('get_post', 'Fetch a post by ID.',
{ id: z.number().int().positive() },
async ({ id }) => {
const r = await safeApi(`/posts/${id}`);
if (r.error) return { content: [{ type: 'text', text: r.error }], isError: true };
return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }] };
}
);
The LLM reads the friendly error and can decide whether to retry, ask the user, or apologize gracefully.
Design checklist #
Before you publish a REST-API-backed MCP server, ask yourself:
| Check | Why |
|---|---|
| Did I rename CRUD endpoints into intent-based tool names? | LLMs reason in intent ("find", "show", "send"), not in HTTP verbs |
| Did I add search/filter tools the raw API lacks? | LLMs cannot fetch 1000 records and scan them efficiently |
| Are my responses preformatted text, not raw JSON? | Token efficiency and ease of reading |
| Did I cap response sizes and add limits? | Prevents context blowouts |
Do error responses use isError: true with a human message? |
Lets the LLM react gracefully |
| Did I write good tool descriptions? | Bad descriptions are the #1 reason LLMs ignore a tool |
Conclusion #
Wrapping a REST API as an MCP server is one of the highest-leverage patterns in the entire MCP ecosystem. Every internal service in your company, every SaaS your team uses, every API you have ever written documentation for — all of them can be exposed to any AI assistant with a few hundred lines of glue.
The trick is to resist the urge to expose the API one-to-one. Build the wrapper for the LLM that will use it, not for the developer who wrote the REST docs. Consolidate related calls, hide low-value endpoints, preformat responses, and add the conveniences the LLM actually wants.
Do that, and you will have an MCP server that feels native — not a transliteration of a REST API into JSON-RPC.
Try it yourself #
With the thoughtful version of the JSONPlaceholder wrapper connected, the LLM can answer real questions instead of dumping raw JSON:
search_postsFound 4 matching posts:• #13 “voluptatum eveniet et nesciunt”
• #42 “commodi ullam sint et excepturi”
• #67 “minima ut consequuntur”
• #88 “totam consequatur expedita”
Want me to fetch the full body of any of these?
The naive list_posts wrapper would have returned all 100 posts and the LLM would have had to scan them itself — chewing through tokens. The thoughtful search_posts tool did the filtering server-side and returned a clean summary.
Up next in AI & MCP
More from this topic
Enjoyed this article?
Get new AI & MCP tutorials delivered. No spam — just code-first articles when they ship.


