Build an API Integration MCP Server
Wrap any REST API as MCP tools so AI assistants can interact with external services. Covers authentication, rate limiting, response mapping, and error handling patterns.
title: "Build an API Integration MCP Server" description: "Wrap any REST API as MCP tools so AI assistants can interact with external services. Covers authentication, rate limiting, response mapping, and error handling patterns." order: 5 keywords:
- mcp api integration
- rest api mcp server
- wrap api as mcp tool
- mcp server external api
- api gateway mcp date: "2026-04-01" level: "intermediate" duration: "30 min"
Learn how to wrap any REST API as MCP tools. This tutorial walks through building an MCP server that integrates with a REST API (using JSONPlaceholder as an example), covering authentication headers, rate limiting, response mapping, and proper error handling.
What You Will Build
An API integration server that wraps the JSONPlaceholder REST API with three tools:
- list_posts -- Fetch posts with optional filtering
- get_post -- Get a single post by ID with comments
- search_posts -- Full-text search across posts
These patterns apply to any REST API -- Stripe, Twilio, your own backend, and more.
The practice of wrapping an external REST API as MCP tools, translating HTTP requests/responses into the MCP tool interface. The AI assistant calls your tool, your server calls the API, and the response flows back.
Project Setup
Scaffold the project
npx mcp-framework create api-integration-server
cd api-integration-server
Create an API client helper
Create src/utils/apiClient.ts:
interface ApiClientOptions {
baseUrl: string;
headers?: Record<string, string>;
timeout?: number;
}
export class ApiClient {
private baseUrl: string;
private headers: Record<string, string>;
private timeout: number;
constructor(options: ApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.headers = {
"Content-Type": "application/json",
...options.headers,
};
this.timeout = options.timeout || 10000;
}
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${this.baseUrl}${path}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url.toString(), {
headers: this.headers,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
async post<T>(path: string, body: unknown): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
}
Building the Tools
ListPostsTool
Create src/tools/ListPostsTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { ApiClient } from "../utils/apiClient.js";
const api = new ApiClient({
baseUrl: "https://jsonplaceholder.typicode.com",
});
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
class ListPostsTool extends MCPTool<typeof inputSchema> {
name = "list_posts";
description = "List posts from the API with optional filtering by user ID";
schema = {
userId: {
type: z.number().optional(),
description: "Filter posts by user ID",
},
limit: {
type: z.number().min(1).max(50).optional(),
description: "Maximum number of posts to return (default: 10, max: 50)",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const params: Record<string, string> = {};
if (input.userId) params._userId = String(input.userId);
let posts = await api.get<Post[]>("/posts", params);
const limit = input.limit || 10;
posts = posts.slice(0, limit);
return JSON.stringify({
count: posts.length,
posts: posts.map((p) => ({
id: p.id,
title: p.title,
preview: p.body.substring(0, 100) + "...",
userId: p.userId,
})),
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
userId: z.number().optional(),
limit: z.number().min(1).max(50).optional(),
});
export default ListPostsTool;
GetPostTool
Create src/tools/GetPostTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { ApiClient } from "../utils/apiClient.js";
const api = new ApiClient({
baseUrl: "https://jsonplaceholder.typicode.com",
});
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
interface Comment {
postId: number;
id: number;
name: string;
email: string;
body: string;
}
class GetPostTool extends MCPTool<typeof inputSchema> {
name = "get_post";
description = "Get a single post by ID, optionally including its comments";
schema = {
id: {
type: z.number().min(1),
description: "The post ID to fetch",
},
includeComments: {
type: z.boolean().optional(),
description: "Whether to include comments (default: false)",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const post = await api.get<Post>(`/posts/${input.id}`);
const result: Record<string, unknown> = {
id: post.id,
title: post.title,
body: post.body,
userId: post.userId,
};
if (input.includeComments) {
const comments = await api.get<Comment[]>(`/posts/${input.id}/comments`);
result.comments = comments.map((c) => ({
author: c.name,
email: c.email,
body: c.body,
}));
result.commentCount = comments.length;
}
return JSON.stringify(result, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
id: z.number().min(1),
includeComments: z.boolean().optional(),
});
export default GetPostTool;
SearchPostsTool
Create src/tools/SearchPostsTool.ts:
import { MCPTool } from "mcp-framework";
import { z } from "zod";
import { ApiClient } from "../utils/apiClient.js";
const api = new ApiClient({
baseUrl: "https://jsonplaceholder.typicode.com",
});
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
class SearchPostsTool extends MCPTool<typeof inputSchema> {
name = "search_posts";
description = "Search posts by keyword in title or body";
schema = {
query: {
type: z.string().min(1),
description: "Search keyword to look for in post titles and bodies",
},
};
async execute(input: z.infer<typeof inputSchema>): Promise<string> {
try {
const allPosts = await api.get<Post[]>("/posts");
const query = input.query.toLowerCase();
const matches = allPosts.filter(
(p) =>
p.title.toLowerCase().includes(query) ||
p.body.toLowerCase().includes(query)
);
return JSON.stringify({
query: input.query,
resultCount: matches.length,
results: matches.slice(0, 20).map((p) => ({
id: p.id,
title: p.title,
preview: p.body.substring(0, 100) + "...",
})),
}, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return JSON.stringify({ error: message });
}
}
}
const inputSchema = z.object({
query: z.string().min(1),
});
export default SearchPostsTool;
Adding Authentication
For real APIs, you will need authentication. Here is how to add API key support:
const api = new ApiClient({
baseUrl: "https://api.example.com/v1",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
},
timeout: 15000,
});
Always read API keys from environment variables. Pass them through the Claude Desktop configuration or your deployment environment. Never commit keys to version control.
Configure in Claude Desktop with environment variables:
{
"mcpServers": {
"api-server": {
"command": "node",
"args": ["/path/to/dist/index.js"],
"env": {
"API_KEY": "your-api-key-here"
}
}
}
}
Official SDK Alternative
The same pattern with the official TypeScript SDK:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "api-server", version: "1.0.0" });
server.tool(
"list_posts",
"List posts with optional user ID filter",
{
userId: z.number().optional(),
limit: z.number().min(1).max(50).optional(),
},
async ({ userId, limit }) => {
const url = userId
? `https://jsonplaceholder.typicode.com/posts?userId=${userId}`
: "https://jsonplaceholder.typicode.com/posts";
const response = await fetch(url);
let posts = await response.json();
posts = posts.slice(0, limit || 10);
return {
content: [{ type: "text" as const, text: JSON.stringify(posts, null, 2) }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Rate Limiting
For production APIs, implement rate limiting to avoid hitting API quotas:
class RateLimiter {
private timestamps: number[] = [];
private maxRequests: number;
private windowMs: number;
constructor(maxRequests: number, windowMs: number) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
async acquire(): Promise<void> {
const now = Date.now();
this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);
if (this.timestamps.length >= this.maxRequests) {
const waitTime = this.timestamps[0] + this.windowMs - now;
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
this.timestamps.push(Date.now());
}
}
// Usage: call rateLimiter.acquire() before each API request
const rateLimiter = new RateLimiter(30, 60000); // 30 requests per minute
Always map API responses to a simplified format before returning them. Raw API responses often contain fields that are irrelevant to the AI assistant and waste tokens. Extract only the fields that are useful for answering user questions.