Exposing a REST API Through MCP: Turning Any API into an AI Tool

Most organizations already have REST APIs. They power internal dashboards, connect microservices, expose data to mobile apps. They work. But now you want an AI agent to use those same services, and agents don’t speak REST. They speak MCP. Do you rewrite everything? No. You build a thin adapter layer that translates between the two protocols, and your existing API stays untouched.

That’s exactly what this project does. We take a standard Flask REST API and wrap it with an MCP server using FastMCP. Any MCP-compatible client, Claude Code, Cursor, Windsurf, can discover and call the API endpoints as tools, without knowing there’s a REST layer underneath.

The REST API

The API is a standard Flask application with CRUD endpoints for managing notes. Nothing special here, just the kind of REST service you’d find in any organization:

notes_bp = Blueprint("notes", __name__)
@notes_bp.route("/api/notes", methods=["GET"])
def list_notes():
return jsonify(get_all_notes())
@notes_bp.route("/api/notes/<int:note_id>", methods=["GET"])
def read_note(note_id: int):
note = get_note(note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note)
@notes_bp.route("/api/notes", methods=["POST"])
def add_note():
data = request.get_json()
if not data or "title" not in data:
return jsonify({"error": "title is required"}), 400
note = create_note(title=data["title"], body=data.get("body", ""))
return jsonify(note), 201
@notes_bp.route("/api/notes/<int:note_id>", methods=["PUT"])
def edit_note(note_id: int):
data = request.get_json()
note = update_note(note_id, title=data.get("title"), body=data.get("body"))
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note)
@notes_bp.route("/api/notes/<int:note_id>", methods=["DELETE"])
def remove_note(note_id: int):
if delete_note(note_id):
return jsonify({"status": "deleted"})
return jsonify({"error": "Note not found"}), 404

Five endpoints: list, get, create, update, delete. The data store is an in-memory dictionary for simplicity, but in a real scenario this would be your existing database, your internal service, your legacy system. The point is that the REST API already exists and works. We don’t want to change it.

The MCP server

This is the core of the project. The MCP server uses FastMCP to expose each REST endpoint as an MCP tool. It uses requests to call the Flask API over HTTP:

import requests
from mcp.server.fastmcp import FastMCP
from settings import API_BASE_URL
mcp = FastMCP(name="notes-api")
BASE = API_BASE_URL
@mcp.tool()
def list_notes() -> str:
"""List all notes stored in the system.
Returns a JSON array of note objects, each containing:
id, title, body, and created_at fields.
"""
response = requests.get(f"{BASE}/api/notes")
return response.text
@mcp.tool()
def get_note(note_id: int) -> str:
"""Get a single note by its ID.
Returns the note object with id, title, body, and created_at fields.
Returns an error if the note is not found.
"""
response = requests.get(f"{BASE}/api/notes/{note_id}")
return response.text
@mcp.tool()
def create_note(title: str, body: str = "") -> str:
"""Create a new note.
Args:
title: The title of the note (required).
body: The body content of the note (optional, defaults to empty string).
Returns the created note object with its assigned id.
"""
response = requests.post(
f"{BASE}/api/notes",
json={"title": title, "body": body},
)
return response.text
@mcp.tool()
def update_note(note_id: int, title: str = "", body: str = "") -> str:
"""Update an existing note by its ID.
Args:
note_id: The ID of the note to update.
title: New title for the note (optional, send empty string to keep current).
body: New body for the note (optional, send empty string to keep current).
Returns the updated note object, or an error if not found.
"""
payload = {}
if title:
payload["title"] = title
if body:
payload["body"] = body
response = requests.put(
f"{BASE}/api/notes/{note_id}",
json=payload,
)
return response.text
@mcp.tool()
def delete_note(note_id: int) -> str:
"""Delete a note by its ID.
Args:
note_id: The ID of the note to delete.
Returns a status confirmation or an error if the note is not found.
"""
response = requests.delete(f"{BASE}/api/notes/{note_id}")
return response.text
if __name__ == "__main__":
mcp.run()

Each @mcp.tool() maps to one REST endpoint. The decorator extracts parameter types from the function signature to build the JSON schema that MCP clients use to understand what parameters to send. The docstring becomes the tool description that the AI agent reads to decide when and how to call each tool. When you run python src/server/main.py, it starts listening on stdio for MCP requests.

The pattern is straightforward: receive the MCP call, translate it into an HTTP request, forward it to the REST API, and return the response. The MCP server knows nothing about the business logic. The REST API knows nothing about MCP. Each side does its job.

The adapter pattern

This is the Adapter Pattern applied at the protocol level. The MCP server adapts the REST interface into the MCP protocol. The REST API doesn’t need to change. The MCP client doesn’t need to know it’s talking to a REST service. The adapter handles the translation:

The MCP client calls create_note("Meeting notes", "Discussed Q3 roadmap"). The MCP server translates this into a POST /api/notes with a JSON body. The Flask API processes it, creates the note, and returns the result. The MCP server passes the response back to the client. The agent sees a tool that creates notes. It doesn’t know or care that there’s an HTTP call in between.

Configuration

To use the MCP server from Claude Code, create a .mcp.json file in your project root:

{
"mcpServers": {
"notes-api": {
"command": "/path/to/venv/bin/python",
"args": ["/path/to/src/server/main.py"]
}
}
}

Claude Code reads this file, launches the MCP server as a subprocess, performs the MCP handshake, and discovers the five tools automatically. The same server works with Cursor, Windsurf, VS Code with Copilot, or any other MCP-compatible client, just point it to the same Python script.

Running it

First, install dependencies:

poetry install

Start the Flask API in one terminal:

make api

You can verify it works with curl:

curl -X POST http://127.0.0.1:5000/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "First note", "body": "Hello from REST"}'

curl http://127.0.0.1:5000/api/notes

With the API running and .mcp.json in place, open Claude Code in the project directory. It discovers the notes-api MCP server and makes all five tools available. You can ask things like “Create a note about the deployment we did today” or “List all my notes” and the agent calls the MCP tools, which call your REST API, automatically.

Taking it further

This POC uses a simple notes API, but the same pattern works with any existing REST service. Your internal APIs, third-party integrations, legacy systems, anything with HTTP endpoints can be wrapped with a thin MCP layer. The REST API stays unchanged, the MCP server handles the translation, and suddenly your existing services become tools that any AI agent can use.

You could also add authentication headers in the MCP server (forwarding API keys or tokens to the REST API), error handling with retry logic, or caching for read-heavy endpoints. The adapter layer is the right place for these cross-cutting concerns.

And that’s all. With a thin MCP adapter on top of any REST API, your existing services become tools that any AI agent can discover and use. The REST API stays unchanged, the MCP server handles the protocol translation, and the standard connects them. Build the adapter once, use it from any MCP client.

Full code in my github account.

AWS SQS 2 HTTP server with Python

Simple service that listens to a sqs queue and bypass the payload to a http server, sending a POST request with the SQS’s payload and a Bearer Token.

The main script read a .env file with the aws credentials and http endpoint.

# AWS CREDENTIALS
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_REGION=eu-west-1
#AWS_PROFILE=xxx
# SQS
SQS_QUEUE_URL=https://sqs.eu-west-1.amazonaws.com/xxx/name
# REST ENDPOINT
HTTP_ENDPOINT=http://localhost:5555
AUTH_BEARER_TOKEN=xxx
import logging

from dotenv import load_dotenv, find_dotenv

_ = load_dotenv(find_dotenv())

from lib.sqs2http import loop_step, get_sqs

for library in ['botocore', 'boto3']:
    logging.getLogger(library).setLevel(logging.WARNING)

logging.basicConfig(
    format='%(asctime)s [%(levelname)s] %(message)s',
    level='INFO',
    datefmt='%d/%m/%Y %X'
)

logger = logging.getLogger(__name__)

if __name__ == "__main__":
    logger.info("sqs2http start")
    sqs = get_sqs()
    while True:
        loop_step(sqs)

The main loop is like this:

def loop_step(sqs):
    response = sqs.receive_message(
        QueueUrl=SQS_QUEUE_URL,
        MaxNumberOfMessages=SQS_MAX_NUMBER_OF_MESSAGES)

    if 'Messages' in response:
        for message in response.get('Messages', []):
            try:
                body = message.get('Body')
                logger.info(body)
                do_post(body)
                remove_from_sqs(sqs, message.get('ReceiptHandle'))

            except Exception as e:
                logger. Exception(e)

To post to the http server we can use requests.

import requests
import json

from settings import HTTP_ENDPOINT, AUTH_BEARER_TOKEN

def do_post(body):
    requests.post(
        url=HTTP_ENDPOINT,
        data=json.dumps(body),
        headers={
            'Authorization': AUTH_BEARER_TOKEN,
            'Content-Type': 'application/json'
        })

With this simple script you can set up a web server that process all incoming SQS messages. Source code in my github.

Working with AngularJS and Silex as Resource provider

This days I’m playing with AngularJS. Angular is a great framework when we’re building complex front-end applications with JavaScript. And the best part is that it’s very simple to understand (and I like simple things indeed). Today we are going to play with Resources. Resources are great when we need to use RestFull resources from the server. In this example we’re going to use Silex in the backend. Let’s start.

First of all we must realize that resources aren’t included in the main AngularJS js file and we need to include angular-resource.js (it comes with Angular package). We don’t really need resources. We can create our http services with AngularJS without using this extra js file but it provides a very clean abstraction (at least for me) and I like it.

    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/angularjs/1.0.7/angular.min.js&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/angularjs/1.0.7/angular-resource.min.js&quot;&gt;&lt;/script&gt;

We’re going to create a simple application with CRUD operations in the table. In the example we will use one simple SqlLite database (included in the github repository)

CREATE TABLE main.messages (
  id INTEGER PRIMARY KEY  NOT NULL ,
  author VARCHAR NOT NULL ,
  message VARCHAR NOT NULL );

Our main (and only one) html file:

&lt;!DOCTYPE html&gt;
&lt;html ng-app=&quot;MessageService&quot;&gt;
&lt;head&gt;
    &lt;title&gt;Angular Resource Example&lt;/title&gt;
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/angularjs/1.0.7/angular.min.js&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://ajax.googleapis.com/ajax/libs/angularjs/1.0.7/angular-resource.min.js&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;js/services.js&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;js/controllers.js&quot;&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;body ng-controller=&quot;MessageController&quot;&gt;

&lt;h2&gt;Message list &lt;a href=&quot;#&quot; ng-click=&quot;refresh()&quot;&gt;refresh&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
    &lt;li ng-repeat=&quot;message in messages&quot;&gt;
        &lt;a href=&quot;#&quot; ng-click=&quot;deleteMessage($index, message.id)&quot;&gt;delete&lt;/a&gt;
        #{{message.id}} - {{message.author}} - {{message.message}}
        &lt;a href=&quot;#&quot; ng-click=&quot;selectMessage($index)&quot;&gt;edit&lt;/a&gt;
    &lt;/li&gt;
&lt;/ul&gt;

&lt;form&gt;
    &lt;input type=&quot;text&quot; ng-model=&quot;author&quot; placeholder=&quot;author&quot;&gt;
    &lt;input type=&quot;text&quot; ng-model=&quot;message&quot; placeholder=&quot;message&quot;&gt;

    &lt;button ng-click=&quot;add()&quot; ng-show='addMode'&gt;Create&lt;/button&gt;
    &lt;button ng-click=&quot;update()&quot; ng-hide='addMode'&gt;Update&lt;/button&gt;
    &lt;button ng-click=&quot;cancel()&quot; ng-hide='addMode'&gt;Cancel&lt;/button&gt;
&lt;/form&gt;
&lt;/body&gt;
&lt;/html&gt;

As we can see we will use ng-app=”MessageService” defined within the js/services.js file:

angular.module('MessageService', ['ngResource']).factory('Message', ['$resource', function ($resource) {
    return $resource('/api/message/resource/:id');
}]);

And our controller in js/controllers.js:

function MessageController($scope, Message) {

    var currentResource;
    var resetForm = function () {
        $scope.addMode = true;
        $scope.author = undefined;
        $scope.message = undefined;
        $scope.selectedIndex = undefined;
    }

    $scope.messages = Message.query();
    $scope.addMode = true;

    $scope.add = function () {
        var key = {};
        var value = {author: $scope.author, message: $scope.message}

        Message.save(key, value, function (data) {
            $scope.messages.push(data);
            resetForm();
        });
    };

    $scope.update = function () {
        var key = {id: currentResource.id};
        var value = {author: $scope.author, message: $scope.message}
        Message.save(key, value, function (data) {
            currentResource.author = data.author;
            currentResource.message = data.message;
            resetForm();
        });
    }

    $scope.refresh = function () {
        $scope.messages = Message.query();
        resetForm();
    };

    $scope.deleteMessage = function (index, id) {
        Message.delete({id: id}, function () {
            $scope.messages.splice(index, 1);
            resetForm();
        });
    };

    $scope.selectMessage = function (index) {
        currentResource = $scope.messages[index];
        $scope.addMode = false;
        $scope.author = currentResource.author;
        $scope.message = currentResource.message;
    }

    $scope.cancel = function () {
        resetForm();
    }
}

Now the backend part. As we said before we will use Silex. We’re going to use also RouteCollections to define our routes (you can read about it here). So our Silex application will be:

&lt;?php
require_once __DIR__ . '/vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Routing\Loader\YamlFileLoader;
use Symfony\Component\Routing\RouteCollection;
use Silex\Application;

$app = new Silex\Application();

$app['routes'] = $app-&gt;extend('routes', function (RouteCollection $routes, Application $app) {
        $loader     = new YamlFileLoader(new FileLocator(__DIR__ . '/config'));
        $collection = $loader-&gt;load('routes.yml');
        $routes-&gt;addCollection($collection);

        return $routes;
    }
);

$app-&gt;register(
    new Silex\Provider\DoctrineServiceProvider(),
    array(
        'db.options' =&gt; array(
            'driver' =&gt; 'pdo_sqlite',
            'path'   =&gt; __DIR__ . '/db/app.db.sqlite',
        ),
    )
);

$app-&gt;run();

We define our routes in the messageResource.yml

getAll:
  path: /resource
  defaults: { _controller: 'Message\MessageController::getAllAction' }
  methods:  [GET]

getOne:
  path: /resource/{id}
  defaults: { _controller: 'Message\MessageController::getOneAction' }
  methods:  [GET]

deleteOne:
  path: /resource/{id}
  defaults: { _controller: 'Message\MessageController::deleteOneAction' }
  methods:  [DELETE]

addOne:
  path: /resource
  defaults: { _controller: 'Message\MessageController::addOneAction' }
  methods:  [POST]

editOne:
  path: /resource/{id}
  defaults: { _controller: 'Message\MessageController::editOneAction' }
  methods:  [POST]

And finally our Resource controller:

&lt;?php
namespace Message;

use Silex\Application;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

class MessageController
{
    public function getAllAction(Application $app)
    {
        return new JsonResponse($app['db']-&gt;fetchAll(&quot;SELECT * FROM messages&quot;));
    }

    public function getOneAction($id, Application $app)
    {
        return new JsonResponse($app['db']
            -&gt;fetchAssoc(&quot;SELECT * FROM messages WHERE id=:ID&quot;, ['ID' =&gt; $id]));
    }

    public function deleteOneAction($id, Application $app)
    {
        return $app['db']-&gt;delete('messages', ['ID' =&gt; $id]);
    }

    public function addOneAction(Application $app, Request $request)
    {
        $payload = json_decode($request-&gt;getContent());;

        $newResource = [
            'id'      =&gt; (integer)$app['db']
                -&gt;fetchColumn(&quot;SELECT max(id) FROM messages&quot;) + 1,
            'author'  =&gt; $payload-&gt;author,
            'message' =&gt; $payload-&gt;message,
        ];
        $app['db']-&gt;insert('messages', $newResource);

        return new JsonResponse($newResource);
    }

    public function editOneAction($id, Application $app, Request $request)
    {
        $payload = json_decode($request-&gt;getContent());;
        $resource = [
            'author'  =&gt; $payload-&gt;author,
            'message' =&gt; $payload-&gt;message,
        ];
        $app['db']-&gt;update('messages', $resource, ['id' =&gt; $id]);

        return new JsonResponse($resource);
    }
}

And that’s all. Our prototype is working with AngularJS and Silex as REST provider. We must take care about one thing. Silex and AngularJS aren’t agree in one thing about REST services. AngularJS removes the trailing slash in some cases. Silex (and Symfony) returns HTTP 302 moved temporaly when we’re trying to access to the resource without the trailing slash but when we’re working with mounted controllers we will obtain a 404 page not found (bug/feature?). That’s because my REST service is /api/message/resource/:id instead of /api/message/:id. If I chose the second one, when angular tries to create a new resource, it will POST /api/message instead of POST /api/message/. We’re using mounted routes in this example:

messages:
  prefix: /message
  resource: messageResource.yml

With one simple Silex application (without mounted routes) in one file it doesn’t happen (we will see HTTP 302 and a new request with the trailing slash). Because of that I use this small hack to bypass the problem.

You can see the full code of the example in my github account

Building a simple API proxy server with PHP

This days I’m playing with Backbone and using public API as a source. The Web Browser has one horrible feature: It don’t allow to fetch any external resource to our host due to the cross-origin restriction. For example if we have a server at localhost we cannot perform one AJAX request to another host different than localhost. Nowadays there is a header to allow it: Access-Control-Allow-Origin. The problem is that the remote server must set up this header. For example I was playing with github’s API and github doesn’t have this header. If the server is my server, is pretty straightforward to put this header but obviously I’m not the sysadmin of github, so I cannot do it. What the solution? One possible solution is, for example, create a proxy server at localhost with PHP. With PHP we can use any remote API with curl (I wrote about it here and here for example). It’s not difficult, but I asked myself: Can we create a dummy proxy server with PHP to handle any request to localhost and redirects to the real server, Instead of create one proxy for each request?. Let’s start. Problably there is one open source solution (tell me if you know it) but I’m on holidays and I want to code a little bit (I now, it looks insane but that’s me 🙂 ).

The idea is:

...
$proxy-&gt;register('github', 'https://api.github.com');
...

And when I type:

http://localhost/github/users/gonzalo123

and create a proxy to :

https://api.github.com/users/gonzalo123

The request method is also important. If we create a POST request to localhost we want a POST request to github too.

This time we’re not going to reinvent the wheel, so we will use symfony componets so we will use composer to start our project:

We create a conposer.json file with the dependencies:

{
    &quot;require&quot;: {
        &quot;symfony/class-loader&quot;:&quot;dev-master&quot;,
        &quot;symfony/http-foundation&quot;:&quot;dev-master&quot;
    }
}

Now

php composer.phar install

And we can start coding. The script will look like this:

register('github', 'https://api.github.com');
$proxy-&gt;run();

foreach($proxy-&gt;getHeaders() as $header) {
    header($header);
}
echo $proxy-&gt;getContent();

As we can see we can register as many servers as we want. In this example we only register github. The application only has two classes:
RestProxy, who extracts the information from the request object and calls to the real server through CurlWrapper.

&lt;?php
namespace RestProxy;

class RestProxy
{
    private $request;
    private $curl;
    private $map;

    private $content;
    private $headers;

    public function __construct(\Symfony\Component\HttpFoundation\Request $request, CurlWrapper $curl)
    {
        $this-&gt;request  = $request;
        $this-&gt;curl = $curl;
    }

    public function register($name, $url)
    {
        $this-&gt;map[$name] = $url;
    }

    public function run()
    {
        foreach ($this-&gt;map as $name =&gt; $mapUrl) {
            return $this-&gt;dispatch($name, $mapUrl);
        }
    }

    private function dispatch($name, $mapUrl)
    {
        $url = $this-&gt;request-&gt;getPathInfo();
        if (strpos($url, $name) == 1) {
            $url         = $mapUrl . str_replace(&quot;/{$name}&quot;, NULL, $url);
            $queryString = $this-&gt;request-&gt;getQueryString();

            switch ($this-&gt;request-&gt;getMethod()) {
                case 'GET':
                    $this-&gt;content = $this-&gt;curl-&gt;doGet($url, $queryString);
                    break;
                case 'POST':
                    $this-&gt;content = $this-&gt;curl-&gt;doPost($url, $queryString);
                    break;
                case 'DELETE':
                    $this-&gt;content = $this-&gt;curl-&gt;doDelete($url, $queryString);
                    break;
                case 'PUT':
                    $this-&gt;content = $this-&gt;curl-&gt;doPut($url, $queryString);
                    break;
            }
            $this-&gt;headers = $this-&gt;curl-&gt;getHeaders();
        }
    }

    public function getHeaders()
    {
        return $this-&gt;headers;
    }

    public function getContent()
    {
        return $this-&gt;content;
    }
}

The RestProxy receive two instances in the constructor via dependency injection (CurlWrapper and Request). This architecture helps a lot in the tests, because we can mock both instances. Very helpfully when building RestProxy.

The RestProxy is registerd within packaist so we can install it using composer installer:

First install componser

curl -s https://getcomposer.org/installer | php

and create a new project:

php composer.phar create-project gonzalo123/rest-proxy proxy

If we are using PHP5.4 (if not, what are you waiting for?) we can run the build-in server

cd proxy
php -S localhost:8888 -t www/

Now we only need to open a web browser and type:

http://localhost:8888/github/users/gonzalo123

The library is very minimal (it’s enough for my experiment) and it does’t allow authorization.

Of course full code is available in github.