Skip to main content
RapidDev - Software Development Agency
mcp-tutorial

How to build an MCP server that wraps an external API

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.

What you'll learn

  • How to wrap REST API endpoints as MCP tools
  • How to forward authentication tokens to external APIs
  • How to handle pagination in API-backed MCP tools
  • How to manage rate limits and retry logic
  • How to structure a multi-endpoint API wrapper server
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced10 min read30-45 minMCP TypeScript SDK 1.x, MCP Python SDK 1.xMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1// TypeScript
2const API_BASE = process.env.API_BASE_URL || "https://api.example.com/v1";
3const API_KEY = process.env.API_KEY;
4
5if (!API_KEY) {
6 console.error("Error: API_KEY environment variable required");
7 process.exit(1);
8}
9
10async 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 }
24
25 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 });
33
34 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 }
40
41 return response.json();
42}

Expected result: A reusable API client function that handles auth headers and error responses.

2

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.

typescript
1// TypeScript
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { z } from "zod";
4
5const server = new McpServer({ name: "api-wrapper", version: "1.0.0" });
6
7// GET endpoint → MCP tool
8server.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});
34
35// POST endpoint → MCP tool
36server.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.

3

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.

typescript
1// TypeScript
2server.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 });
23
24 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 };
34
35 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.

4

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.

typescript
1// TypeScript
2async function apiRequestWithRetry<T>(
3 path: string,
4 options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
5 retries = 2
6): 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 };
12
13 // Rate limited — wait and retry
14 if (err.status === 429 && attempt < retries) {
15 const waitMs = 2000 * (attempt + 1); // exponential backoff
16 console.error(`Rate limited, waiting ${waitMs}ms before retry`);
17 await new Promise(resolve => setTimeout(resolve, waitMs));
18 continue;
19 }
20
21 // Server error — retry
22 if (err.status && err.status >= 500 && attempt < retries) {
23 await new Promise(resolve => setTimeout(resolve, 1000));
24 continue;
25 }
26
27 throw error; // Non-retryable error
28 }
29 }
30 throw new Error("Max retries exceeded");
31}

Expected result: Transient API failures and rate limits are handled automatically with exponential backoff.

5

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.

typescript
1// TypeScript
2server.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

src/index.ts
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { z } from "zod";
4
5const 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); }
8
9async 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}
20
21const server = new McpServer({ name: "api-wrapper-server", version: "1.0.0" });
22
23server.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}));
26
27server.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});
40
41server.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});
50
51server.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});
63
64server.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});
77
78const 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.

ChatGPT Prompt

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.

MCP Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.