DEV Community

Alain Airom (Ayrom)
Alain Airom (Ayrom)

Posted on

From REST to ReAct: Transforming Legacy APIs into MCP Tools for AI Agents

A didactical guide to transform API calls into MCP Tools for agentic era!

Introduction

For more than two decades, Representational State Transfer (REST) APIs have served as the undisputed backbone of application interoperability. Designed for explicit execution, REST endpoints expect strict compliance, deterministic input types, and structured query definitions.

However, as Large Language Models (LLMs) rapidly pivot into autonomous AI Agents, traditional APIs become an architectural bottleneck. Rather than relying on hardcoded programmatic logic, agents rely on semantic discovery and reasoning loops. To bridge this structural divide, Anthropic open-sourced the Model Context Protocol (MCP) — a uniform standard that reshapes how AI interfaces with legacy data environments.


Why AI Agents Prefer MCP Over Traditional API Calls

When a traditional software client wants to read or write data, it calls an explicit endpoint with pre-compiled query variables. When an AI Agent tries to do the same over standard REST, it faces three main challenges:

  • Context Window Bloat: To understand an API, an agent needs its documentation. Injecting thousands of lines of an OpenAPI/Swagger JSON specification into an LLM’s system prompt consumes massive context tokens.
  • Brittle Input Mapping: LLMs are non-deterministic. If a REST API requires a strict format, slight deviations in the model’s output will trigger 400 Bad Request or 422 Unprocessable Entity errors, which break the application's runtime.
  • Lack of Native Discovery: Standard HTTP endpoints do not natively describe what they do to an external intelligence engine; they require pre-mapped code glue to translate text intent into actual network requests.

The Paradigm Shift

TRADITIONAL CLIENT WORKFLOW (Deterministic)
[ User UI Action ] ──► [ Hardcoded Client Proxy ] ──► [ Fixed HTTP Route ] ──► [ REST API Server ]

MODERN AI AGENT WORKFLOW (Autonomous via MCP)
[ User Text Query ] ──► [ LLM Reasoning Brain ] ──► [ Dynamic JSON-RPC Tool Call ] ──► [ MCP Server Bridge ] ──► [ Downstream REST API ]
Enter fullscreen mode Exit fullscreen mode
| Architectural Dimension | Traditional REST API Integration                      | Model Context Protocol (MCP) Server                          |
| ----------------------- | ----------------------------------------------------- | ------------------------------------------------------------ |
| **Inference Handling**  | Expects strict parameters mapped at build-time.       | Exposes self-describing interfaces that LLMs bind to at runtime. |
| **Discovery Mechanism** | Manual mapping or external Swagger parsing.           | Native `tools/list` handshake schema built into the protocol. |
| **Error Handling**      | Returns raw structural status codes (e.g., 404, 500). | Converts exceptions into semantic text streams ideal for agent reflection. |

Enter fullscreen mode Exit fullscreen mode

Architecture of the 4-App Reference Journey

To see how this works in practice, let’s explore a microservice-driven library system across four distinct applications. Together, they demonstrate how to transition an application from a raw backend database all the way to an autonomous agent system.

┌─────────────────────────────────────────────────────────────────┐
│                        User Browsers                            │
└─────┬──────────────┬──────────────┬──────────────┬──────────────┘
      │              │              │              │
  :8001          :8002          :8003          :8004
  App 1          App 2          App 3          App 4
  API Server     Client         MCP Server     Agent GUI
  (FastAPI)      (FastAPI)      (Node.js)      (FastAPI+WS)
      ▲              │              │              │
      │         HTTP Proxy          │         WebSocket
      │              ▼              │              │
      └──────────────┘         MCP Protocol       │
      ▲                             │              │
      └─────────────────────────────┘              │
           HTTP REST API calls               Ollama LLM
           (list_books, get_book, etc)    (granite local)
Enter fullscreen mode Exit fullscreen mode
  • App 1 (Core API Server): The data backbone. A Python FastAPI service hosting our core book collection database on port 8001.
  • App 2 (API Client): The traditional layout. A standard Python frontend companion on port 8002 using hardcoded HTTP requests to display data.
  • App 3 (MCP Server Bridge): The architectural adapter. A Node.js/TypeScript instance on port 8003 that wraps App 1’s endpoints into 8 discoverable, schemas-validated MCP tools.
  • App 4 (Autonomous ReAct Agent): The final intelligence layer on port 8004. It communicates with App 3 over JSON-RPC, launching a Reason-Act loop via a local LLM to execute user tasks.


Structural Code Breakdown

Step 1: Exposing the Legacy Endpoints (App 1)

Our baseline application handles raw CRUD operations. The endpoint below shows how traditional backends use query parameters to narrow down records:

"""
App 1 - Library API Server
A sample REST API server exposing CRUD operations for a library book management system.
Runs on port 8001.
"""
import os
import uuid
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from dotenv import load_dotenv

load_dotenv()

HOST = os.getenv("API_SERVER_HOST", "0.0.0.0")
PORT = int(os.getenv("API_SERVER_PORT", "8001"))

app = FastAPI(
    title="Library API Server",
    description="A sample REST API for managing a library's book collection",
    version="1.0.0",
    docs_url="/docs",
    redoc_url="/redoc",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")

# ─── Models ────────────────────────────────────────────────────────────────────

class BookCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200, description="Book title")
    author: str = Field(..., min_length=1, max_length=100, description="Author name")
    genre: str = Field(..., min_length=1, max_length=50, description="Book genre")
    year: int = Field(..., ge=1000, le=2100, description="Publication year")
    available: bool = Field(True, description="Whether the book is available for borrowing")
    description: Optional[str] = Field(None, max_length=500, description="Book description")

class BookUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    author: Optional[str] = Field(None, min_length=1, max_length=100)
    genre: Optional[str] = Field(None, min_length=1, max_length=50)
    year: Optional[int] = Field(None, ge=1000, le=2100)
    available: Optional[bool] = None
    description: Optional[str] = Field(None, max_length=500)

class Book(BaseModel):
    id: str
    title: str
    author: str
    genre: str
    year: int
    available: bool
    description: Optional[str] = None
    created_at: str
    updated_at: str

# ─── In-Memory Data Store ──────────────────────────────────────────────────────

def now_iso() -> str:
    return datetime.utcnow().isoformat() + "Z"

BOOKS: dict[str, dict] = {}

SEED_BOOKS = [
    {"title": "The Hitchhiker's Guide to the Galaxy", "author": "Douglas Adams", "genre": "Science Fiction", "year": 1979, "available": True, "description": "A comedic science fiction series following the adventures of Arthur Dent."},
    {"title": "Dune", "author": "Frank Herbert", "genre": "Science Fiction", "year": 1965, "available": True, "description": "An epic science fiction novel set in a distant future amidst a feudal interstellar society."},
    {"title": "The Name of the Wind", "author": "Patrick Rothfuss", "genre": "Fantasy", "year": 2007, "available": False, "description": "The story of Kvothe, a legendary figure in his world, telling his life story to a chronicler."},
    {"title": "Sapiens: A Brief History of Humankind", "author": "Yuval Noah Harari", "genre": "Non-Fiction", "year": 2011, "available": True, "description": "A narrative history from the Stone Age to the twenty-first century."},
    {"title": "The Pragmatic Programmer", "author": "David Thomas & Andrew Hunt", "genre": "Technology", "year": 1999, "available": True, "description": "Classic guide for software developers covering best practices and principles."},
    {"title": "1984", "author": "George Orwell", "genre": "Dystopian Fiction", "year": 1949, "available": False, "description": "A dystopian social science fiction novel and cautionary tale about the dangers of totalitarianism."},
    {"title": "Clean Code", "author": "Robert C. Martin", "genre": "Technology", "year": 2008, "available": True, "description": "A handbook of agile software craftsmanship."},
    {"title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "genre": "Fantasy", "year": 1954, "available": True, "description": "An epic high-fantasy novel set in Middle-earth."},
]

for book_data in SEED_BOOKS:
    bid = str(uuid.uuid4())
    BOOKS[bid] = {
        "id": bid,
        **book_data,
        "created_at": now_iso(),
        "updated_at": now_iso(),
    }

# ─── Routes ───────────────────────────────────────────────────────────────────

@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def dashboard(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

@app.get("/health", tags=["System"])
async def health():
    return {"status": "ok", "service": "library-api-server", "book_count": len(BOOKS)}

@app.get("/books", response_model=list[Book], tags=["Books"])
async def list_books(genre: Optional[str] = None, available: Optional[bool] = None, search: Optional[str] = None):
    """List all books with optional filtering by genre, availability, or search term."""
    books = list(BOOKS.values())
    if genre:
        books = [b for b in books if b["genre"].lower() == genre.lower()]
    if available is not None:
        books = [b for b in books if b["available"] == available]
    if search:
        q = search.lower()
        books = [b for b in books if q in b["title"].lower() or q in b["author"].lower() or q in (b.get("description") or "").lower()]
    return books

@app.post("/books", response_model=Book, status_code=201, tags=["Books"])
async def create_book(book: BookCreate):
    """Create a new book in the library."""
    bid = str(uuid.uuid4())
    new_book = {
        "id": bid,
        **book.model_dump(),
        "created_at": now_iso(),
        "updated_at": now_iso(),
    }
    BOOKS[bid] = new_book
    return new_book

@app.get("/books/{book_id}", response_model=Book, tags=["Books"])
async def get_book(book_id: str):
    """Get a specific book by ID."""
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail=f"Book '{book_id}' not found")
    return BOOKS[book_id]

@app.put("/books/{book_id}", response_model=Book, tags=["Books"])
async def update_book(book_id: str, updates: BookUpdate):
    """Update an existing book's details."""
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail=f"Book '{book_id}' not found")
    book = BOOKS[book_id]
    patch = updates.model_dump(exclude_none=True)
    book.update(patch)
    book["updated_at"] = now_iso()
    return book

@app.delete("/books/{book_id}", tags=["Books"])
async def delete_book(book_id: str):
    """Delete a book from the library."""
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail=f"Book '{book_id}' not found")
    del BOOKS[book_id]
    return {"message": f"Book '{book_id}' deleted successfully"}

@app.get("/genres", tags=["Books"])
async def list_genres():
    """Get all unique genres in the library."""
    genres = sorted(set(b["genre"] for b in BOOKS.values()))
    return {"genres": genres}

@app.post("/books/{book_id}/borrow", response_model=Book, tags=["Books"])
async def borrow_book(book_id: str):
    """Mark a book as borrowed (unavailable)."""
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail=f"Book '{book_id}' not found")
    if not BOOKS[book_id]["available"]:
        raise HTTPException(status_code=409, detail="Book is already borrowed")
    BOOKS[book_id]["available"] = False
    BOOKS[book_id]["updated_at"] = now_iso()
    return BOOKS[book_id]

@app.post("/books/{book_id}/return", response_model=Book, tags=["Books"])
async def return_book(book_id: str):
    """Mark a book as returned (available)."""
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail=f"Book '{book_id}' not found")
    if BOOKS[book_id]["available"]:
        raise HTTPException(status_code=409, detail="Book is already available")
    BOOKS[book_id]["available"] = True
    BOOKS[book_id]["updated_at"] = now_iso()
    return BOOKS[book_id]

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host=HOST, port=PORT, reload=True)
Enter fullscreen mode Exit fullscreen mode

Step 2: The Traditional Integration Strategy (App 2)

In a traditional client architecture, every backend change requires updating a corresponding client method. If App 1 introduces new query parameters or pagination rules, App 2’s hardcoded proxy functions will fail or drop the data unless manually re-compiled and updated.

"""
App 2 - API Client Application
Consumes the Library API Server (App 1) and provides a rich dashboard GUI.
Runs on port 8002.
"""
import os
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
from dotenv import load_dotenv

load_dotenv()

HOST = os.getenv("API_CLIENT_HOST", "0.0.0.0")
PORT = int(os.getenv("API_CLIENT_PORT", "8002"))
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8001")

app = FastAPI(
    title="Library API Client",
    description="Client application that consumes the Library API Server",
    version="1.0.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")

# ─── Pydantic Models ──────────────────────────────────────────────────────────

class BookCreate(BaseModel):
    title: str
    author: str
    genre: str
    year: int
    available: bool = True
    description: Optional[str] = None

class BookUpdate(BaseModel):
    title: Optional[str] = None
    author: Optional[str] = None
    genre: Optional[str] = None
    year: Optional[int] = None
    available: Optional[bool] = None
    description: Optional[str] = None

# ─── Helper ───────────────────────────────────────────────────────────────────

async def api_request(method: str, path: str, **kwargs):
    """Proxy request to the upstream API server."""
    async with httpx.AsyncClient(timeout=10.0) as client:
        url = f"{API_SERVER_URL}{path}"
        response = await client.request(method, url, **kwargs)
        return response

# ─── Dashboard Route ──────────────────────────────────────────────────────────

@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def dashboard(request: Request):
    return templates.TemplateResponse("index.html", {
        "request": request,
        "api_server_url": API_SERVER_URL
    })

@app.get("/config")
async def get_config():
    return {
        "api_server_url": API_SERVER_URL,
        "client_port": PORT,
    }

# ─── Proxy Routes ─────────────────────────────────────────────────────────────

@app.get("/api/health")
async def health_check():
    try:
        r = await api_request("GET", "/health")
        api_data = r.json() if r.status_code == 200 else {"status": "error"}
        return {"client_status": "ok", "api_server": api_data, "api_server_url": API_SERVER_URL}
    except Exception as e:
        return {"client_status": "ok", "api_server": {"status": "unreachable", "error": str(e)}, "api_server_url": API_SERVER_URL}

@app.get("/api/books")
async def list_books(genre: Optional[str] = None, available: Optional[bool] = None, search: Optional[str] = None):
    params = {}
    if genre: params["genre"] = genre
    if available is not None: params["available"] = str(available).lower()
    if search: params["search"] = search
    r = await api_request("GET", "/books", params=params)
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.text)
    return r.json()

@app.post("/api/books", status_code=201)
async def create_book(book: BookCreate):
    r = await api_request("POST", "/books", json=book.model_dump())
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.json().get("detail", r.text))
    return r.json()

@app.get("/api/books/{book_id}")
async def get_book(book_id: str):
    r = await api_request("GET", f"/books/{book_id}")
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.json().get("detail", r.text))
    return r.json()

@app.put("/api/books/{book_id}")
async def update_book(book_id: str, updates: BookUpdate):
    r = await api_request("PUT", f"/books/{book_id}", json=updates.model_dump(exclude_none=True))
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.json().get("detail", r.text))
    return r.json()

@app.delete("/api/books/{book_id}")
async def delete_book(book_id: str):
    r = await api_request("DELETE", f"/books/{book_id}")
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.json().get("detail", r.text))
    return r.json()

@app.post("/api/books/{book_id}/borrow")
async def borrow_book(book_id: str):
    r = await api_request("POST", f"/books/{book_id}/borrow")
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.json().get("detail", r.text))
    return r.json()

@app.post("/api/books/{book_id}/return")
async def return_book(book_id: str):
    r = await api_request("POST", f"/books/{book_id}/return")
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.json().get("detail", r.text))
    return r.json()

@app.get("/api/genres")
async def list_genres():
    r = await api_request("GET", "/genres")
    if not r.is_success:
        raise HTTPException(status_code=r.status_code, detail=r.text)
    return r.json()

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host=HOST, port=PORT, reload=True)
Enter fullscreen mode Exit fullscreen mode

Step 3: Mapping APIs to Self-Describing MCP Tools (App 3)

Instead of forcing our AI Agent to guess raw URL syntax, App 3 translates the FastAPI endpoints into standard tool definitions using the official @modelcontextprotocol/sdk. By integrating a Zod validation schema, the model learns the exact parameters it needs to provide.

// index-app3.ts
server.registerTool(
  "list_books",
  {
    description: "List all books in the library with optional filters",
    inputSchema: z.object({
      genre: z.string().optional().describe("Filter by genre (e.g., Fantasy, Technology, Sci-Fi)"),
      available: z.boolean().optional().describe("Filter by availability (true = available, false = borrowed)"),
      search: z.string().optional().describe("Search string matching titles, authors, or descriptions"),
    }),
  },
  async ({ genre, available, search }) => {
    const params: Record<string, string> = {};
    if (genre) params.genre = genre;
    if (available !== undefined) params.available = String(available);
    if (search) params.search = search;

    // Standard network call down to the core App 1 infrastructure
    const result = await apiCall("GET", "/books", undefined, params);
    const books = result as Array<Record<string, any>>;

    return {
      content: [{
        type: "text",
        text: `Found ${books.length} book(s):\n` + 
              books.map(b => `• "${b.title}" by ${b.author} [ID: ${b.id}] (${b.available ? 'Available' : 'Borrowed'})`).join("\n"),
      }],
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

Step 4: Orchestrating the Autonomous ReAct Loop (App 4)

When a user types an unstructured query (like “Find me a good tech book and check if it’s available”), App 4 boots up a Reason-Act (ReAct) loop.

The agent queries the tool list from App 3, thinks through its plan, triggers the list_books tool, observes the results, and formulates its final answer. The parser function below demonstrates how the loop reads the model's text reasoning steps to execute tools dynamically:

# main-app4.py
def parse_react_step(text: str) -> dict:
    """
    Parses the LLM's text output to extract either the next 
    structured tool invocation or the terminal 'Final Answer'.
    """
    pre_final, _, _ = text.partition("Final Answer:")

    action_match = re.search(r"Action:\s*(\w+)", pre_final, re.IGNORECASE)
    input_match = re.search(r"Action Input:\s*(\{.+?\})", pre_final, re.DOTALL | re.IGNORECASE)
    final_match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL | re.IGNORECASE)

    if action_match:
        try:
            return {
                "type": "action", 
                "tool": action_match.group(1).strip(), 
                "args": json.loads(input_match.group(1))
            }
        except Exception:
            # Fallback if the LLM output poorly-formatted JSON arguments
            return {"type": "action", "tool": action_match.group(1).strip(), "args": {}}

    elif final_match:
        return {"type": "final", "answer": final_match.group(1).strip()}

    return {"type": "fallback"}
Enter fullscreen mode Exit fullscreen mode


Summary: The Modern Interface Matrix

Migrating legacy APIs to MCP tools shifts how we think about system integration. By introducing an adapter layer that translates raw endpoints into self-describing tool schemas, applications gain a new level of architectural flexibility.

When your core service changes, your agent doesn’t need to be rewritten. Instead, it reads the updated tool schemas at runtime, dynamically structuring its queries to meet the new backend requirements on the fly.

Thanks for reading 🫵

Links

Top comments (0)