Wrap any REST API as MCP tools so AI clients can interact with external services through natural language. Create tools for each API endpoint, handle authentication forwarding, manage pagination for list endpoints, and implement proper error handling for network failures and rate limits.
Building an MCP Server That Wraps a REST API
One of the most common MCP server patterns is wrapping an existing REST API so AI clients can interact with it naturally. Instead of the user manually constructing API calls, the AI reads tool descriptions, generates the right parameters, and your MCP server translates those into HTTP requests.
This tutorial shows how to build an API wrapper server that handles authentication, pagination, error mapping, and rate limiting. The patterns work for any REST API — GitHub, Stripe, Slack, Jira, or your own internal services.
Prerequisites
- MCP TypeScript SDK installed (@modelcontextprotocol/sdk)
- A REST API to wrap (with API key or OAuth token)
- Understanding of HTTP methods, headers, and status codes
- Familiarity with MCP tool registration
Step-by-step guide
Create a typed HTTP client for the API
Create a typed HTTP client for the API
Start by building a reusable HTTP client that handles authentication, base URL, and common headers. This centralizes API configuration so individual tools do not repeat boilerplate. Load the API key from environment variables and set default headers for all requests.
1// TypeScript2const API_BASE = process.env.API_BASE_URL || "https://api.example.com/v1";3const API_KEY = process.env.API_KEY;45if (!API_KEY) {6 console.error("Error: API_KEY environment variable required");7 process.exit(1);8}910async function apiRequest<T>(11 path: string,12 options: {13 method?: string;14 body?: unknown;15 params?: Record<string, string>;16 } = {}17): Promise<T> {18 const url = new URL(path, API_BASE);19 if (options.params) {20 Object.entries(options.params).forEach(([k, v]) =>21 url.searchParams.set(k, v)22 );23 }2425 const response = await fetch(url.toString(), {26 method: options.method || "GET",27 headers: {28 "Authorization": `Bearer ${API_KEY}`,29 "Content-Type": "application/json",30 },31 body: options.body ? JSON.stringify(options.body) : undefined,32 });3334 if (!response.ok) {35 const errorText = await response.text();36 throw Object.assign(new Error(`HTTP ${response.status}: ${errorText}`), {37 status: response.status,38 });39 }4041 return response.json();42}Expected result: A reusable API client function that handles auth headers and error responses.
Register tools for each API endpoint
Register tools for each API endpoint
Map each REST API endpoint to an MCP tool. Use descriptive tool names and detailed descriptions so the AI knows when to use each tool. The input schema should match the API's required and optional parameters. Return API responses as JSON text content.
1// TypeScript2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { z } from "zod";45const server = new McpServer({ name: "api-wrapper", version: "1.0.0" });67// GET endpoint → MCP tool8server.registerTool("list-projects", {9 description: "List all projects with optional status filter. Returns project names, IDs, and statuses.",10 inputSchema: {11 status: z.enum(["active", "archived", "all"]).default("active")12 .describe("Filter by project status"),13 page: z.number().int().min(1).default(1).describe("Page number"),14 perPage: z.number().int().min(1).max(100).default(20)15 .describe("Results per page (1-100)"),16 },17}, async ({ status, page, perPage }) => {18 try {19 const data = await apiRequest<any>("/projects", {20 params: {21 status: status === "all" ? "" : status,22 page: String(page),23 per_page: String(perPage),24 },25 });26 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };27 } catch (error) {28 return {29 content: [{ type: "text", text: `Error: ${(error as Error).message}` }],30 isError: true,31 };32 }33});3435// POST endpoint → MCP tool36server.registerTool("create-project", {37 description: "Create a new project. Returns the created project object.",38 inputSchema: {39 name: z.string().min(1).max(100).describe("Project name"),40 description: z.string().max(500).optional().describe("Project description"),41 },42}, async ({ name, description }) => {43 try {44 const data = await apiRequest<any>("/projects", {45 method: "POST",46 body: { name, description },47 });48 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };49 } catch (error) {50 return {51 content: [{ type: "text", text: `Error: ${(error as Error).message}` }],52 isError: true,53 };54 }55});Expected result: Each API endpoint is accessible as an MCP tool with validated parameters.
Handle pagination for list endpoints
Handle pagination for list endpoints
Many APIs return paginated results. Create tools that expose pagination parameters (page, perPage) and include pagination metadata in the response. For AI-friendly pagination, include a summary of how many total results exist and whether more pages are available.
1// TypeScript2server.registerTool("search-items", {3 description: "Search for items with pagination. Returns items and pagination metadata.",4 inputSchema: {5 query: z.string().min(1).describe("Search query"),6 page: z.number().int().min(1).default(1).describe("Page number"),7 perPage: z.number().int().min(1).max(50).default(20).describe("Results per page"),8 },9}, async ({ query, page, perPage }) => {10 try {11 const data = await apiRequest<{12 items: any[];13 total: number;14 page: number;15 per_page: number;16 }>("/items/search", {17 params: {18 q: query,19 page: String(page),20 per_page: String(perPage),21 },22 });2324 const totalPages = Math.ceil(data.total / data.per_page);25 const result = {26 items: data.items,27 pagination: {28 currentPage: data.page,29 totalPages,30 totalItems: data.total,31 hasNextPage: data.page < totalPages,32 },33 };3435 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };36 } catch (error) {37 return {38 content: [{ type: "text", text: `Error: ${(error as Error).message}` }],39 isError: true,40 };41 }42});Expected result: The AI can page through results by calling the tool repeatedly with incrementing page numbers.
Implement rate limit handling with retry
Implement rate limit handling with retry
APIs enforce rate limits, and the AI may trigger many calls in succession. Handle 429 (Too Many Requests) responses by reading the Retry-After header and either waiting or returning a descriptive error. Implement a retry wrapper for transient failures.
1// TypeScript2async function apiRequestWithRetry<T>(3 path: string,4 options: { method?: string; body?: unknown; params?: Record<string, string> } = {},5 retries = 26): Promise<T> {7 for (let attempt = 0; attempt <= retries; attempt++) {8 try {9 return await apiRequest<T>(path, options);10 } catch (error) {11 const err = error as Error & { status?: number };1213 // Rate limited — wait and retry14 if (err.status === 429 && attempt < retries) {15 const waitMs = 2000 * (attempt + 1); // exponential backoff16 console.error(`Rate limited, waiting ${waitMs}ms before retry`);17 await new Promise(resolve => setTimeout(resolve, waitMs));18 continue;19 }2021 // Server error — retry22 if (err.status && err.status >= 500 && attempt < retries) {23 await new Promise(resolve => setTimeout(resolve, 1000));24 continue;25 }2627 throw error; // Non-retryable error28 }29 }30 throw new Error("Max retries exceeded");31}Expected result: Transient API failures and rate limits are handled automatically with exponential backoff.
Add an API docs resource for AI context
Add an API docs resource for AI context
Register a resource that describes the API endpoints available through your MCP server. This helps the AI understand which tool to call for a given task. For complex API wrapper servers with many endpoints, RapidDev can help design the tool structure and documentation for optimal AI usability.
1// TypeScript2server.registerResource("api-docs", "docs://api", {3 description: "API endpoint documentation for available tools",4 mimeType: "text/markdown",5}, async (uri) => ({6 contents: [{7 uri: uri.href,8 text: [9 "# Available API Operations",10 "",11 "## Projects",12 "- **list-projects**: List all projects, filter by status",13 "- **create-project**: Create a new project with name and description",14 "- **get-project**: Get project details by ID",15 "",16 "## Items",17 "- **search-items**: Full-text search with pagination",18 "- **create-item**: Create item in a project",19 "- **update-item**: Update item fields",20 "",21 "## Notes",22 "- All list endpoints support pagination (page, perPage)",23 "- Rate limit: 100 requests/minute",24 ].join("\n"),25 }],26}));Expected result: The AI can read the API documentation resource to understand which tools are available and when to use each.
Complete working example
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { z } from "zod";45const API_BASE = process.env.API_BASE_URL || "https://api.example.com/v1";6const API_KEY = process.env.API_KEY;7if (!API_KEY) { console.error("API_KEY required"); process.exit(1); }89async function api<T>(path: string, opts: { method?: string; body?: unknown; params?: Record<string,string> } = {}): Promise<T> {10 const url = new URL(path, API_BASE);11 if (opts.params) Object.entries(opts.params).forEach(([k,v]) => url.searchParams.set(k,v));12 const res = await fetch(url.toString(), {13 method: opts.method || "GET",14 headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },15 body: opts.body ? JSON.stringify(opts.body) : undefined,16 });17 if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}: ${await res.text()}`), { status: res.status });18 return res.json();19}2021const server = new McpServer({ name: "api-wrapper-server", version: "1.0.0" });2223server.registerResource("api-docs", "docs://api", { description: "API docs", mimeType: "text/markdown" }, async (uri) => ({24 contents: [{ uri: uri.href, text: "# API\n- list-projects\n- get-project\n- create-project\n- search-items" }],25}));2627server.registerTool("list-projects", {28 description: "List projects with optional status filter and pagination",29 inputSchema: {30 status: z.enum(["active","archived","all"]).default("active").describe("Status filter"),31 page: z.number().int().min(1).default(1).describe("Page"),32 perPage: z.number().int().min(1).max(100).default(20).describe("Per page"),33 },34}, async ({ status, page, perPage }) => {35 try {36 const data = await api<any>("/projects", { params: { status, page: String(page), per_page: String(perPage) } });37 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };38 } catch (e) { return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; }39});4041server.registerTool("get-project", {42 description: "Get a project by ID",43 inputSchema: { id: z.string().describe("Project ID") },44}, async ({ id }) => {45 try {46 const data = await api<any>(`/projects/${encodeURIComponent(id)}`);47 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };48 } catch (e) { return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; }49});5051server.registerTool("create-project", {52 description: "Create a new project",53 inputSchema: {54 name: z.string().min(1).max(100).describe("Project name"),55 description: z.string().max(500).optional().describe("Description"),56 },57}, async ({ name, description }) => {58 try {59 const data = await api<any>("/projects", { method: "POST", body: { name, description } });60 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };61 } catch (e) { return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; }62});6364server.registerTool("search-items", {65 description: "Search items with pagination",66 inputSchema: {67 query: z.string().min(1).describe("Search query"),68 page: z.number().int().min(1).default(1).describe("Page"),69 perPage: z.number().int().min(1).max(50).default(20).describe("Per page"),70 },71}, async ({ query, page, perPage }) => {72 try {73 const data = await api<any>("/items/search", { params: { q: query, page: String(page), per_page: String(perPage) } });74 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };75 } catch (e) { return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; }76});7778const transport = new StdioServerTransport();79await server.connect(transport);80console.error("API wrapper MCP server running");Common mistakes when building an MCP server that wraps an external API
Why it's a problem: Exposing the API key in tool descriptions or responses
How to avoid: Load API keys from environment variables. Never include them in tool descriptions, error messages, or response content.
Why it's a problem: Not handling rate limits from the external API
How to avoid: Catch 429 responses and either retry with backoff or return an isError message telling the AI to wait.
Why it's a problem: Returning raw API error bodies to the AI
How to avoid: Parse API error responses and return clean, actionable messages. Raw error bodies may contain internal details or be unreadable.
Why it's a problem: Not URL-encoding path parameters
How to avoid: Always use encodeURIComponent() for path parameters to prevent URL injection and handle special characters.
Why it's a problem: Creating one mega-tool that handles all endpoints
How to avoid: Create separate tools for each logical API operation. The AI chooses better when tools are focused and well-described.
Best practices
- Create one MCP tool per logical API operation (list, get, create, update, delete)
- Use descriptive tool names that match the API domain (list-projects, not api-get)
- Include pagination parameters in all list tools with sensible defaults
- Handle 429 rate limits with exponential backoff retry logic
- Register an API docs resource so the AI knows which tools are available
- URL-encode all path and query parameters to prevent injection
- Return clean error messages — never expose raw API error bodies or credentials
- Log all API requests to stderr with timing for debugging and monitoring
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I want to wrap [API name]'s REST API as MCP tools. Show me how to create a reusable HTTP client with auth headers, register tools for GET and POST endpoints with Zod input schemas, handle pagination, and manage rate limits with retry logic.
Create an MCP tool that wraps the [API name] [endpoint] endpoint. Accept [parameters] as inputs, call the API with proper authentication, handle errors and rate limits, and return the response as formatted JSON.
Frequently asked questions
How many API endpoints should one MCP server wrap?
One MCP server typically wraps one API service (e.g., GitHub, Stripe). Keep it under 15-20 tools. If the API is very large, create focused servers for different domains (github-issues-server, github-repos-server).
Should I proxy authentication or use a service account?
For most cases, use a service account API key stored in environment variables. Proxy authentication (forwarding user tokens) is only needed when per-user access control matters. Service accounts are simpler and more reliable.
How do I handle APIs that require OAuth instead of API keys?
Store the OAuth access token and refresh token securely. Implement token refresh logic in your HTTP client. When the access token expires, refresh it automatically before retrying the API call.
What if the API response is very large?
Truncate or summarize large responses before returning them. Include total count and pagination info so the AI knows there is more data. Large responses waste tokens and may exceed context limits.
Can I wrap GraphQL APIs as MCP tools?
Yes. Create one tool per GraphQL operation (query or mutation). The tool handler constructs the GraphQL request body and sends it via HTTP POST. This works exactly like REST wrapping.
How do I test my API wrapper tools?
Use the MCP Inspector to invoke each tool manually. Verify that authentication works, parameters are passed correctly, pagination returns expected results, and error handling covers common API failures. For complex API integrations, the RapidDev team can help build comprehensive test suites.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation