tutorial12 min read

How to Build an MCP Server for Your API

Convert any REST API into an MCP server so AI tools can use it. Covers endpoint mapping, authentication, error handling, and deployment with Python and TypeScript examples.

WI
William Wang · Apr 9, 2026

William Wang — Founder of TokRepo & GEOScore AI. Building tools for AI developer productivity and search visibility.

How to Build an MCP Server for Your API
Table of Contents

Learn how to wrap any REST API as an MCP server so Claude Code, Cursor, and other AI tools can interact with it through natural language. This guide covers endpoint mapping, secure authentication, error handling, and deployment.

Prerequisites

  • A REST API you want to expose to AI tools
  • Python 3.10+ with uv, or Node.js 16+ with npm
  • API documentation or endpoint list for your target API
  • Claude Code or Cursor for testing

Architecture: REST to MCP Mapping

REST ConceptMCP EquivalentWhen to Use
GET endpoints (no params)ResourcesRead-only data retrieval
GET with query paramsToolsLLM decides what to query
POST/PUT/DELETEToolsActions with side effects
Auth headersEnvironment variablesInjected at server startup
PaginationMultiple tool callsLLM handles iteration

Key principle: GET endpoints with no parameters become Resources. Everything else becomes a Tool.

Step 1 — Plan Your Endpoint Mapping

Example for a project management API:

GET    /projects              → Resource: projects://list
GET    /projects/{id}         → Resource: projects://{id}
POST   /projects              → Tool: create_project
PUT    /projects/{id}         → Tool: update_project
DELETE /projects/{id}         → Tool: delete_project
GET    /projects/{id}/tasks   → Tool: list_tasks (has query params)
POST   /projects/{id}/tasks   → Tool: create_task

Step 2 — Set Up the Project

uv init api-mcp-server
cd api-mcp-server
uv venv && source .venv/bin/activate
uv add "mcp[cli]" httpx
touch server.py

Step 3 — Build the Server

"""MCP Server wrapping a Project Management REST API."""
import os
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP

API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.example.com/v1")
API_KEY = os.environ.get("API_KEY", "")

mcp = FastMCP("project-api")


async def api_request(
    method: str, path: str,
    params: dict[str, Any] | None = None,
    json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Make an authenticated request to the REST API."""
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    }
    async with httpx.AsyncClient() as client:
        response = await client.request(
            method, f"{API_BASE_URL}{path}",
            headers=headers, params=params,
            json=json_body, timeout=30.0,
        )
        response.raise_for_status()
        return response.json()


# --- Resources (GET, read-only) ---

@mcp.resource("projects://list")
async def list_projects() -> str:
    """List all projects in the workspace."""
    data = await api_request("GET", "/projects")
    projects = data.get("projects", [])
    lines = [f"- [{p['id']}] {p['name']} ({p['status']})" for p in projects]
    return "\n".join(lines) if lines else "No projects found."


# --- Tools (actions with side effects) ---

@mcp.tool()
async def create_project(name: str, description: str = "") -> str:
    """Create a new project.

    Args:
        name: Project name
        description: Optional project description
    """
    data = await api_request("POST", "/projects",
                             json_body={"name": name, "description": description})
    return f"Created project '{data['name']}' with ID: {data['id']}"


@mcp.tool()
async def list_tasks(
    project_id: str,
    status: str | None = None,
    assignee: str | None = None,
    limit: int = 20,
) -> str:
    """List tasks in a project with optional filters.

    Args:
        project_id: The project to list tasks for
        status: Filter by 'todo', 'in_progress', or 'done' (optional)
        assignee: Filter by assignee email (optional)
        limit: Max tasks to return (default 20)
    """
    params: dict[str, Any] = {"limit": min(limit, 100)}
    if status: params["status"] = status
    if assignee: params["assignee"] = assignee

    data = await api_request("GET", f"/projects/{project_id}/tasks", params=params)
    tasks = data.get("tasks", [])
    if not tasks:
        return "No tasks found."

    lines = [
        f"- [{t['id']}] {t['title']} | {t['status']} | {t.get('assignee', 'unassigned')}"
        for t in tasks
    ]
    return "\n".join(lines)


@mcp.tool()
async def create_task(
    project_id: str, title: str,
    description: str = "", priority: str = "medium",
) -> str:
    """Create a new task in a project.

    Args:
        project_id: The project to create the task in
        title: Task title
        description: Task description (optional)
        priority: 'low', 'medium', 'high', or 'urgent'
    """
    data = await api_request(
        "POST", f"/projects/{project_id}/tasks",
        json_body={"title": title, "description": description, "priority": priority},
    )
    return f"Created task '{data['title']}' (ID: {data['id']})"


if __name__ == "__main__":
    mcp.run(transport="stdio")

Step 4 — Handle Authentication Securely

Never hardcode API keys. Use environment variables:

{
  "mcpServers": {
    "project-api": {
      "command": "uv",
      "args": ["--directory", "/path/to/api-mcp-server", "run", "server.py"],
      "env": {
        "API_BASE_URL": "https://api.example.com/v1",
        "API_KEY": "sk-your-api-key-here"
      }
    }
  }
}
⚠️

Step 5 — Error Handling

Wrap API errors gracefully so the LLM gets useful feedback:

import httpx

@mcp.tool()
async def safe_api_call(endpoint: str) -> str:
    """Robust error handling example."""
    try:
        data = await api_request("GET", endpoint)
        return str(data)
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"Not found: {endpoint}"
        elif e.response.status_code == 403:
            return "Permission denied. Check your API key."
        elif e.response.status_code == 429:
            return "Rate limited. Try again shortly."
        return f"API error {e.response.status_code}: {e.response.text[:200]}"
    except httpx.TimeoutException:
        return "Request timed out."

Step 6 — Schema Design for LLMs

LLMs work best with simple, clear schemas:

# BAD — nested objects confuse LLMs
@mcp.tool()
async def create_user(data: dict) -> str: ...

# GOOD — flat, typed parameters with descriptions
@mcp.tool()
async def create_user(
    name: str, email: str, role: str = "member"
) -> str:
    """Create a new user account.

    Args:
        name: Full name of the user
        email: Email address (must be unique)
        role: User role — 'member', 'admin', or 'viewer'
    """

Return human-readable text, not raw JSON:

# BAD
return json.dumps(api_response)

# GOOD
return f"Created user {data['name']} (ID: {data['id']}) with role {data['role']}"

Step 7 — Deploy as Remote HTTP Server

For production, use streamable HTTP transport:

mcp = FastMCP("project-api", stateless_http=True)

# ... define tools and resources ...

if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)

Connect from Claude Code:

claude mcp add --transport http project-api https://your-server.com:8080/mcp

Step 8 — Handling Large APIs

For APIs with 50+ endpoints, split into focused servers:

api-users-mcp/      → User management tools
api-projects-mcp/   → Project tools
api-billing-mcp/    → Billing and payment tools

Each server stays focused. Claude Code handles multiple MCP servers simultaneously.

💡

Step 9 — Test Everything

# Visual debugging
npx @modelcontextprotocol/inspector uv run server.py

# Dev mode
mcp dev server.py

# Test in Claude Code
claude mcp add --transport stdio project-api \
  --env API_KEY=sk-test --env API_BASE_URL=https://api.example.com/v1 \
  -- uv --directory /path/to/server run server.py

FAQ

Q: How do I convert a REST API to MCP? A: Map GET endpoints (no params) to MCP Resources, and everything else (POST, PUT, DELETE, parameterized GET) to MCP Tools. Use environment variables for authentication.

Q: Should I create one large MCP server or multiple small ones? A: Multiple focused servers (under 30 tools each) perform better. LLMs choose more accurately when tool count is manageable.

Q: Can I use OAuth instead of API keys? A: Yes, but OAuth flows are more complex. Store the access token as an environment variable, and handle token refresh in your server's lifespan manager.

Next Steps