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 Requestor422 Unprocessable Entityerrors, 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 ]
| 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. |
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)
-
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
8002using hardcoded HTTP requests to display data. -
App 3 (MCP Server Bridge): The architectural adapter. A Node.js/TypeScript instance on port
8003that 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)
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)
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"),
}],
};
}
);
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"}
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
- IBM Bob: https://bob.ibm.com/
- Bob’s documentation: https://bob.ibm.com/docs/ide
- Github repository for this post: https://github.com/aairom/api-2-mcp









Top comments (0)