<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Abdurrahman Rajab - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Abdurrahman Rajab - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 13 Jul 2026 04:12:41 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/a0m0rajab/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Your Own MCP Server and Publish Your ChatGPT App with Supabase Auth and DigitalOcean ]]>
                </title>
                <description>
                    <![CDATA[ A new type of app is emerging with the development of LLMs and AI-native apps. It lives inside an AI chat (like ChatGPT) rather than being a fully native web or mobile app. In this tutorial, you'll le ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-your-own-mcp-server-and-publish-your-chatgpt-app/</link>
                <guid isPermaLink="false">6a4fc672a2e4b5543646e329</guid>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abdurrahman Rajab ]]>
                </dc:creator>
                <pubDate>Thu, 09 Jul 2026 16:04:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f7fdd4f8-d0c0-44ee-aaf5-f3277522e32c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A new type of app is emerging with the development of LLMs and AI-native apps. It lives inside an AI chat (like ChatGPT) rather than being a fully native web or mobile app.</p>
<p>In this tutorial, you'll learn how to build an MCP (Model Context Protocol) server from scratch, including a UI you can use as a ChatGPT app with authentication and a database.</p>
<p>You'll go through the process of building, testing, adding the ChatGPT app as a connector, and submitting it to publish to the app directory. This will let you build the app on three levels:</p>
<ul>
<li><p>Level one: you will build your basic MCP Server that returns textual data.</p>
</li>
<li><p>Level two: you will build a UI for your MCP Server to be used within an LLM UI.</p>
</li>
<li><p>Level three: you will add authentication and a database to your MCP Server.</p>
</li>
</ul>
<p>To fully understand this article, you'll need to have basic knowledge of:</p>
<ul>
<li><p>Web development</p>
</li>
<li><p>JavaScript</p>
</li>
<li><p>React and React Native</p>
</li>
<li><p>SQL and databases</p>
</li>
</ul>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-what-is-an-mcp-server">What is an MCP Server?</a></p>
<ul>
<li><a href="#heading-what-can-you-do-with-an-mcp-server">What Can You Do with an MCP Server?</a></li>
</ul>
</li>
<li><p><a href="#heading-level-1-how-to-build-your-own-mcp-server">Level 1: How to Build Your Own MCP Server</a></p>
<ul>
<li><p><a href="#heading-step-0-prepare-your-project">Step 0: Prepare your project</a></p>
</li>
<li><p><a href="#heading-step-1-create-a-nodejs-server">Step 1: Create a Node.js Server</a></p>
</li>
<li><p><a href="#heading-step-2-setting-up-mcp-server-sdk">Step 2: Setting Up MCP Server SDK</a></p>
</li>
<li><p><a href="#heading-step-3-add-mcp-server-tools-create-and-add-a-todo">Step 3: Add MCP Server Tools – Create and Add a Todo</a></p>
</li>
<li><p><a href="#heading-step-4-list-todos-from-mcp-server">Step 4: List Todos from MCP Server</a></p>
</li>
<li><p><a href="#heading-step-5-add-todo-complete-functions">Step 5: Add Todo Complete Functions</a></p>
</li>
<li><p><a href="#heading-step-6-connect-your-mcp-server-with-the-nodejs-server">Step 6: Connect Your MCP Server with the Node.js Server</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-test-your-mcp-server">How to Test Your MCP Server</a></p>
</li>
<li><p><a href="#heading-level-2-how-to-build-the-ui">Level 2: How to Build the UI</a></p>
<ul>
<li><p><a href="#heading-step-1-create-the-html-file-to-show-the-ui">Step 1: Create the HTML File to Show the UI</a></p>
</li>
<li><p><a href="#heading-step-2-add-a-javascript-module-to-handle-mcp-server-data">Step 2: Add a JavaScript Module to Handle MCP Server Data</a></p>
</li>
<li><p><a href="#heading-step-3-styling-your-ui">Step 3: Styling your UI</a></p>
</li>
<li><p><a href="#heading-step-4-add-the-ui-to-your-mcp-server">Step 4: Add the UI to your MCP Server</a></p>
</li>
<li><p><a href="#heading-step-5-update-your-mcp-server-to-handle-the-ui">Step 5: Update Your MCP Server to Handle the UI</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-test-your-chatgpt-app">How to Test Your ChatGPT App</a></p>
</li>
<li><p><a href="#heading-level-3-how-to-add-supabase-auth-and-database-to-the-mcp-server">Level 3: How to Add Supabase (Auth and Database) to the MCP Server</a></p>
<ul>
<li><p><a href="#heading-step-1-create-the-todos-table">Step 1: Create the Todos Table</a></p>
</li>
<li><p><a href="#heading-step-2-enabling-the-mcp-server-to-connect-with-supabase-auth">Step 2: Enabling the MCP Server to Connect with Supabase Auth</a></p>
</li>
<li><p><a href="#heading-step-3-create-a-proxy-server-for-the-mcp-server-to-handle-the-auth">Step 3: Create a Proxy Server for the MCP Server to Handle the Auth</a></p>
</li>
<li><p><a href="#heading-step-4-implementing-the-consent-and-login-page">Step 4: Implementing the Consent and Login Page</a></p>
</li>
<li><p><a href="#heading-step-5-testing-the-oauth-implementation-with-mcp-server-inspector">Step 5: Testing the OAuth Implementation with MCP Server Inspector</a></p>
</li>
<li><p><a href="#heading-step-6-adding-oauth-security-to-your-mcp-server-tools">Step 6: Adding OAuth Security to Your MCP Server Tools</a></p>
</li>
<li><p><a href="#heading-step-7-updating-the-mcp-server-function-to-handle-the-authentication">Step 7: Updating the MCP Server Function to Handle the Authentication</a></p>
</li>
<li><p><a href="#heading-step-8-testing-the-server-with-supabase">Step 8: Testing the Server with Supabase</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-deploy-your-mcp-server-to-digitalocean">How to Deploy Your MCP Server to DigitalOcean</a></p>
</li>
<li><p><a href="#heading-how-to-publish-your-chatgpt-app">How to Publish Your ChatGPT App</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
<li><p><a href="#heading-acknowledgments">Acknowledgments</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-what-is-an-mcp-server">What is an MCP Server?</h2>
<p>A <a href="https://www.freecodecamp.org/news/how-the-model-context-protocol-works/">Model Context Protocol</a> (MCP) server is a program that exposes tools, resources, and prompts to an AI application through a standard protocol. An MCP server can provide read-only context, callable tools, or reusable prompt templates that help extend what an AI application can do.</p>
<p>A developer builds or configures the MCP server, and an MCP client inside a host application connects to it. The application can then allow the model to discover available capabilities and, when appropriate, invoke tools or fetch resources via the MCP protocol to help complete a task.</p>
<h3 id="heading-what-can-you-do-with-an-mcp-server">What Can You Do with an MCP Server?</h3>
<p>An MCP server lets an AI application work with information and systems outside the model itself. For example, it can help the model look up current information, save and retrieve user data, search documents, or trigger actions in another application.</p>
<p>In practice, one MCP server might connect to an online database, while another might work with files on your local machine. This makes it possible to build AI workflows that are more useful, practical, and connected to real tools.</p>
<h2 id="heading-level-1-how-to-build-your-own-mcp-server">Level 1: How to Build Your Own MCP Server</h2>
<p>In this tutorial, you'll learn how to build an MCP server using the default HTTP server from Node.js, Supabase for the database and authentication, and the official MCP server SDK. Then you'll deploy it to DigitalOcean and publish your app on ChatGPT.</p>
<p>That means you'll do two steps here:</p>
<ul>
<li><p>First step: connect your deployed MCP server to ChatGPT as an app/connector so it can be used within ChatGPT.</p>
</li>
<li><p>Second step: submit the app for review and, if approved, publish it to the ChatGPT app directory.</p>
</li>
</ul>
<p>The MCP server SDK isn't the only tool or framework for building your own MCP server. You can use other SDKs and tools for that if you prefer. But to simplify the first steps, here I've decided to use the more straightforward tools.</p>
<h3 id="heading-step-0-prepare-your-project">Step 0: Prepare your project</h3>
<p>You're going to write a full project here, so you should start by creating packages and initializing the project. To do this, follow these steps:</p>
<ul>
<li><p>Create a new folder with the project name. For this example, you can use <code>mcp_todo</code>.</p>
</li>
<li><p>Navigate to this new folder.</p>
</li>
<li><p>Open the terminal in this folder.</p>
</li>
<li><p>Initialize the npm project with <code>npm init --init-type=module -y</code> to create a JavaScript package file and add the packages to the project with ES6 support.</p>
</li>
<li><p>Initialize Git with <code>git init</code> in the project to enable version control and track changes.</p>
</li>
<li><p>Install related packages that you're going to use in your project:</p>
<ul>
<li><p>The packages are Supabase, the MCP SDK (which we'll cover in step 2), and the zod validation package for validating LLM inputs and data.</p>
<pre><code class="language-shell">npm install @modelcontextprotocol/sdk zod @supabase/supabase-js
</code></pre>
</li>
</ul>
</li>
<li><p>Create a <code>.gitignore</code> file and add the <code>node_modules</code> to it so that it won't be tracked by Git.</p>
</li>
<li><p>Add the current state of the project to your Git tracker by writing the following:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "init project"</code></p>
</li>
</ul>
</li>
</ul>
<p>With this, you've created a new project for yourself that you can use as a starting point for managing and following the project.</p>
<h3 id="heading-step-1-create-a-nodejs-server">Step 1: Create a Node.js Server</h3>
<p>To start the project, you'll need to create a simple Node.js server, which you can do by creating a new file named <code>server.js</code> and writing the following code:</p>
<pre><code class="language-javascript">import { createServer } from "node:http";

const port = Number(process.env.PORT ?? 8787);

const httpServer = createServer(async (req, res) =&gt; {

    console.log(`${req.method} ${req.url}`);

    if (!req.url) {

        res.writeHead(400).end("Missing URL");

        return;

    }

    const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);

    res.writeHead(404).end("Not Found");

});

httpServer.listen(port, () =&gt; {
    console.log(`Todo MCP server listening on http://localhost:${port}, press Ctrl+C to stop`);
});
</code></pre>
<p>This is a simple Node server that you'll use as the base for building your MCP Server.</p>
<p>To build your MCP server, you'll need to set it up using the MCP Server SDK. After that, you'll need to define two things: the tools you'll show the LLM and the UI and resources the LLM will use to render.</p>
<p>To define the tools and UI concepts, you'll use the MCP Server SDK.</p>
<h3 id="heading-step-2-setting-up-mcp-server-sdk">Step 2: Setting Up MCP Server SDK</h3>
<p>To set up and start the MCP server, you need to have the following:</p>
<ul>
<li><p>Tools: The functions exposed by MCP Server to an LLM, enabling the LLM to interact with the server and external systems. Like calling an API, performing a computation, or querying a database.</p>
</li>
<li><p>Resources (optional): Data the MCP Server shares with an LLM. For example, a file, database schema, or an HTML UI to use inside the LLM Chat UI as an embedded frame.</p>
</li>
</ul>
<p>You can start the server by adding this line of code at the top of the server.js file:</p>
<pre><code class="language-javascript">import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

function createTodoServer() {
    const server = new McpServer({ name: "todo-app", version: "0.1.0" });
    return server;
}
</code></pre>
<p>Then add a tool and resources using the following function signature:</p>
<pre><code class="language-javascript">server.registerTool(
    "NAME",
    {},
    async (args, meta) =&gt; { }
);
</code></pre>
<p>You can think about the tool registrar as your endpoint to the MCP Server. The LLM will check it and, based on the name and metadata, start processing the data using the arguments and results you have in this tool.</p>
<p>Today, you're going to build three simple tools:</p>
<ul>
<li><p>Add todo</p>
</li>
<li><p>Update todo</p>
</li>
<li><p>List todos</p>
</li>
</ul>
<p>They all look a bit similar, but you'll see how to write them all to understand the concepts in the next sections.</p>
<h3 id="heading-step-3-add-mcp-server-tools-create-and-add-a-todo">Step 3: Add MCP Server Tools – Create and Add a Todo</h3>
<p>To start with, when adding todos, you'll need a simple in-memory array to manipulate. You can create the array outside the create server function to access it throughout the server.</p>
<pre><code class="language-javascript">let todos = [];// outside the createTodoServer function block
let nextId = 1; // outside the createTodoServer function block (this is a mock id for your todos)
</code></pre>
<p>After the array, you'll need to have two more supporting functions: first, the validator for the tools, which specifies the expected input types from the LLM.</p>
<p>At the top of the file, you should import the zod library:</p>
<pre><code class="language-javascript">import { z } from "zod";
</code></pre>
<p>Then you can write the helper function to validate it and tell the LLM what to expect from them:</p>
<pre><code class="language-javascript">const addTodoInputSchema = {
    title: z.string().min(1),
}; // outside the createTodoServer function block
</code></pre>
<p>Next, you'll need the return function, which you can use with other functions to have a unified return function for the tools</p>
<pre><code class="language-javascript">const replyWithTodos = (message) =&gt; ({
    content: message ? [{ type: 'text', text: message }] : [],
    structuredContent: { tasks: todos },
}); //outside the createTodoServer function block
</code></pre>
<p>Then you can register the add todo function in the server, inside the createTodoServer function block, before <code>return server</code>:</p>
<pre><code class="language-javascript">server.registerTool(
    'add_todo',
    {
        title: 'Add todo',
        description: 'Creates a todo item with the given title.',
        inputSchema: addTodoInputSchema,
        _meta: {
            'openai/toolInvocation/invoking': 'Adding todo',
            'openai/toolInvocation/invoked': 'Added todo',
        },
    },
    async (args) =&gt; {
        const title = args?.title?.trim?.() ?? '';
        if (!title) return replyWithTodos('Missing title.');
        const todo = { id: `todo-${nextId++}`, title, completed: false };
        todos = [...todos, todo];
        return replyWithTodos(`${todo.title}`);
    },
); // inside the createTodoServer function block
</code></pre>
<p>In the above code, you've added the tool name and used a simple approach to add the todos to the in-memory array you already identified. The trick here is to validate the data before adding it and create the related object for it.</p>
<p>In the metadata, you've added the title, description, inputSchema, and _meta for OpenAI to use while rendering this. You'll get a rendering, add a todo when the AI adds it, and have the latest version of the added todo when it’s finished.</p>
<p>At the same time, you've added the input schema so the LLM knows what to provide when invoking your server, and you've added a reply helper function to handle your todos. It’s a simple function that shows the todos in a structured way for LLMs to understand.</p>
<h3 id="heading-step-4-list-todos-from-mcp-server">Step 4: List Todos from MCP Server</h3>
<p>To list the todos, you can use a simple list function to show the todos without any changes. In the code below, you use the same concept for naming, metadata, and description context as you provided before. You're also using the previous helper function to return the todos that you have in memory. You should write this code inside the createTodoServer function block.</p>
<pre><code class="language-javascript">server.registerTool(
  'list_todos',
  {
    title: 'List todos',
    description: 'Lists all todo items.',
    _meta: {
      'openai/toolInvocation/invoking': 'Listing todos',
      'openai/toolInvocation/invoked': 'Listed todos',
    },
  },
  async () =&gt; {
    return replyWithTodos();
  },
);
</code></pre>
<h3 id="heading-step-5-add-todo-complete-functions">Step 5: Add Todo Complete Functions</h3>
<p>To complete and edit todos, you can create a new tool with that name that takes the todo ID and returns the updated todos. To do this, you need to add the helper function for validating the request outside the createTodoServer:</p>
<pre><code class="language-javascript">const completeTodoInputSchema = {
    id: z.string().min(1),
};
</code></pre>
<p>Then inside the createTodoServer function, you can add the following:</p>
<pre><code class="language-javascript">server.registerTool(
    'complete_todo',

    {
        title: 'Complete todo',
        description: 'Marks a todo as done by id.',
        inputSchema: completeTodoInputSchema,
        _meta: {
            'openai/toolInvocation/invoking': 'Completing todo',
            'openai/toolInvocation/invoked': 'Completed todo',
        },
    },

    async (args) =&gt; {
        const id = args?.id;
        if (!id) return replyWithTodos('Missing todo id.');
        const todo = todos.find((task) =&gt; task.id === id);
        if (!todo) {
            return replyWithTodos(`Todo ${id} was not found.`);
        }
        todos = todos.map((task) =&gt;
            task.id === id ? { ...task, completed: true } : task,
        );
        return replyWithTodos(`Completed "${todo.title}".`);
    },
);
</code></pre>
<p>In this tool, you used the same function definition as for list todos, while adding extra guards to check whether the LLM has returned the ID and whether that ID is correct. You should always manually check the data you have before processing it, since LLMs can hallucinate and aren't required to validate their inputs.</p>
<p>Now you can commit your code to Git and track it in the tree:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add MCP todo server"</code></p>
</li>
</ul>
<h3 id="heading-step-6-connect-your-mcp-server-with-the-nodejs-server">Step 6: Connect Your MCP Server with the Node.js Server</h3>
<p>Since you have written the main functions for the MCP server, you need to connect your MCP server to the Node.js HTTP server.</p>
<p>To do that, you need to write the streamable function and the related code. You will use this code on top of the server code from step 1 as a replacement, since it includes more functions to handle the MCP server.</p>
<p>First, import the StreamableHTTPServerTransport function:</p>
<pre><code class="language-javascript">import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
</code></pre>
<p>Then you can copy the next code and replace it with the server code, which has the server's structure, to use in your project.</p>
<pre><code class="language-javascript">const port = Number(process.env.PORT ?? 8787);
const MCP_PATH = '/mcp';

const httpServer = createServer(async (req, res) =&gt; {
    if (!req.url) {
        res.writeHead(400).end('Missing URL');
        return;
    }

    const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);

    // handle the options call for the endpoint
    if (req.method === 'OPTIONS' &amp;&amp; url.pathname === MCP_PATH) {
        res.writeHead(204, {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
            'Access-Control-Allow-Headers': 'content-type, mcp-session-id',
            'Access-Control-Expose-Headers': 'Mcp-Session-Id',
        });
        res.end();
        return;
    }

    // handles normal get method for the main link
    if (req.method === 'GET' &amp;&amp; url.pathname === '/') {
        res.writeHead(200, { 'content-type': 'text/plain' }).end('Todo MCP server');
        return;
    }
    // here you are handling your MCP calls with streamable HTTP
    const MCP_METHODS = new Set(['POST', 'GET', 'DELETE']);
    if (url.pathname === MCP_PATH &amp;&amp; req.method &amp;&amp; MCP_METHODS.has(req.method)) {
        res.setHeader('Access-Control-Allow-Origin', '*');
        res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
        const server = createTodoServer();
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined, // stateless mode
            enableJsonResponse: true,
        });
        res.on('close', () =&gt; {
            transport.close();
            server.close();
        });
        try {
            await server.connect(transport);
            await transport.handleRequest(req, res);
        } catch (error) {
            console.error('Error handling MCP request:', error);
            if (!res.headersSent) {
                res.writeHead(500).end('Internal server error');
            }
        }
        return;
    }
    res.writeHead(404).end('Not Found');
});

httpServer.listen(port, () =&gt; {
    console.log(
        `Todo MCP server listening on http://localhost:${port}${MCP_PATH}`,
    );
});
</code></pre>
<p>In this code, you're running the main HTTP server to handle the requests. The server exposes a /mcp endpoint for MCP clients and connects each request to a stateless MCP server using Streamable HTTP.</p>
<p>Now you can commit your code to Git and track it in the tree:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add MCP server functions"</code></p>
</li>
</ul>
<h2 id="heading-how-to-test-your-mcp-server">How to Test Your MCP Server</h2>
<p>Now you can test the basic structure of your MCP server by running the following code:</p>
<pre><code class="language-shell">node server.js
</code></pre>
<p>By using this command, you'll run the server you created in the previous steps. It will make it active and listen to changes at <code>http://localhost:8787/mcp</code>. After running <a href="http://server.js">server.js</a>, you need to open the inspector, a tool that helps you see the MCP server registration and the endpoints and tools you need to use and run in a secure environment.</p>
<pre><code class="language-shell">npx @modelcontextprotocol/inspector@latest --server-url http://localhost:8787/mcp --transport http
</code></pre>
<p>When you run the previous command, you can see that you have a connection to your MCP, and you need to run it and use it through the inspector UI. Using the inspector UI will help you test your MCP server without connecting it to any external services and test the inputs and outputs locally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/ebe3380f-e95e-48dd-a3e5-9236ec72e8f1.png" alt="Showing MCP Server Inspector Too" style="display:block;margin:0 auto" width="1920" height="1080" loading="lazy">

<p>To test your tools, connect to the server first, and then you can see and explore them.</p>
<p>After writing this code, you may wonder: what UI could I show the user through an LLM? If you run your project right now, you'll only get text results as LLM chat answers. But if you build a UI, you can improve your LLM's experience. In the next section, that's what we'll tackle.</p>
<h2 id="heading-level-2-how-to-build-the-ui">Level 2: How to Build the UI</h2>
<p>With the previous code, you built a simple MCP server that adds todos to a todo list and marks them as complete from the app. Now you're going to explore the registerResource tool, which registers a UI resource of your design so ChatGPT can use it.</p>
<p>Resources are the LLM-specific data provided by your MCP Server. You can share your UI with the LLM so it can use it to display additional data and widgets in the chat.</p>
<p>To share the UI, you need to have an HTML file that relies on your MCP server data and uses the MCP server. So for that, you'll create a new HTML file.</p>
<h3 id="heading-step-1-create-the-html-file-to-show-the-ui">Step 1: Create the HTML File to Show the UI</h3>
<p>The TodoHTML you provided earlier should be an HTML file that can communicate with the Server and the ChatGPT UI. The UI will look like the following image:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/40db34a6-d29c-4304-9dd9-895252fb071b.png" alt="UI Style Inside ChatGPT" style="display:block;margin:0 auto" width="908" height="702" loading="lazy">

<p>To build such a UI you saw previously, you need to create a <code>public/todo-widget.html</code> file and write the following structured code:</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;title&gt;Todo list&lt;/title&gt;
    &lt;style&gt;&lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;main&gt;
    &lt;/main&gt;
    &lt;script type="module"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Then inside <code>&lt;main&gt;</code> tag, you should add the following:</p>
<pre><code class="language-html">      &lt;h2&gt;Todo list&lt;/h2&gt;
      &lt;form id="add-form" autocomplete="off"&gt;
        &lt;input id="todo-input" name="title" placeholder="Add a task" /&gt;
        &lt;button type="submit"&gt;Add&lt;/button&gt;
      &lt;/form&gt;
      &lt;ul id="todo-list"&gt;&lt;/ul&gt;
</code></pre>
<p>You can see it’s just simple HTML tags that allow you to have the header, form with an input, and an unordered list with <code>id = todo-list</code>. But the tricky part is the JavaScript module you're going to add to it.</p>
<h3 id="heading-step-2-add-a-javascript-module-to-handle-mcp-server-data">Step 2: Add a JavaScript Module to Handle MCP Server Data.</h3>
<p>To add the JavaScript module and code, you'll write all the code below inside the <code>&lt;script type="module"&gt;&lt;/script&gt;</code> tag.</p>
<p>First, you need to identify the elements by selecting the HTML tag IDs you provided to them in the HTML code:</p>
<pre><code class="language-javascript">const listEl = document.querySelector("#todo-list");
const formEl = document.querySelector("#add-form");
const inputEl = document.querySelector("#todo-input");
</code></pre>
<p>Then you can use these elements to extract data from the ChatGPT response using some special <code>windows.openai</code> code. This will allow you to receive results and responses from ChatGPT while using your MCP server.</p>
<p>For this case, you'll use the following:</p>
<ul>
<li><p><code>window.openai.callTool</code></p>
</li>
<li><p><code>window.openai?.toolOutput</code></p>
</li>
</ul>
<p><code>callTool</code> calls the tools from your MCP server by name, and <code>toolOutput</code> is the result of the tools you get from your MCP.</p>
<p>To create the first todos and show them, you can use the <code>toolOutput</code> and get the output from there to use in your UI. Here's a code example:</p>
<pre><code class="language-javascript">let tasks = [...(window.openai?.toolOutput?.tasks ?? [])];
</code></pre>
<p>You can then loop through all tasks to add them to the list element:</p>
<pre><code class="language-javascript">const render = () =&gt; {
    listEl.innerHTML = '';

    tasks.forEach((task) =&gt; {
        const li = document.createElement('li');
        li.dataset.id = task.id;
        li.dataset.completed = String(Boolean(task.completed));
        const label = document.createElement('label');
        label.style.display = 'flex';
        label.style.alignItems = 'center';
        label.style.gap = '10px';
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = Boolean(task.completed);
        const span = document.createElement('span');
        span.textContent = task.title;
        label.appendChild(checkbox);
        label.appendChild(span);
        li.appendChild(label);
        listEl.appendChild(li);
    });
};
</code></pre>
<p>You can call this function to loop through the tasks from the OpenAI result and print them on the screen.</p>
<p>You can add the update function to update tasks to be completed with the following code:</p>
<pre><code class="language-javascript">const updateFromResponse = (response) =&gt; {
    if (response?.structuredContent?.tasks) {
        tasks = response.structuredContent.tasks;
        render();
    }
};
</code></pre>
<p>In the code above, you received a new response from the AI and an update form via the function. This function will get the todos list from the LLM and re-render the HTML to show the todos:</p>
<pre><code class="language-javascript">const handleSetGlobals = (event) =&gt; {
    const globals = event.detail?.globals;
    if (!globals?.toolOutput?.tasks) return;
    tasks = globals.toolOutput.tasks;
    render();
};
</code></pre>
<p>In the next code block, you'll handle the form response in the updateFormResponse function and set event listeners to update the code when changes are detected:</p>
<pre><code class="language-javascript">
window.addEventListener("openai:set_globals", handleSetGlobals, {
    passive: true,
});

const mutateTasksLocally = (name, payload) =&gt; {
    if (name === "add_todo") {
        tasks = [
            ...tasks,
            { id: crypto.randomUUID(), title: payload.title, completed: false },
        ];
    }

    if (name === "complete_todo") {
        tasks = tasks.map((task) =&gt;
            task.id === payload.id ? { ...task, completed: true } : task
        );
    }

    if (name === "set_completed") {
        tasks = tasks.map((task) =&gt;
            task.id === payload.id
                ? { ...task, completed: payload.completed }
                : task
        );
    }
    render();
};

const callTodoTool = async (name, payload) =&gt; {
    if (window.openai?.callTool) {
        const response = await window.openai.callTool(name, payload);
        updateFromResponse(response);
        return;
    }
    mutateTasksLocally(name, payload);
};

formEl.addEventListener("submit", async (event) =&gt; {
    event.preventDefault();
    const title = inputEl.value.trim();
    if (!title) return;
    await callTodoTool("add_todo", { title });
    inputEl.value = "";
});

listEl.addEventListener("change", async (event) =&gt; {
    const checkbox = event.target;
    if (!checkbox.matches('input[type="checkbox"]')) return;
    const id = checkbox.closest("li")?.dataset.id;
    if (!id) return;
    if (!checkbox.checked) {
        if (window.openai?.callTool) {
            checkbox.checked = true;
            return;
        }
        mutateTasksLocally("set_completed", { id, completed: false });
        return;
    }
    await callTodoTool("complete_todo", { id });
});

render();
</code></pre>
<h3 id="heading-step-3-styling-your-ui">Step 3: Styling your UI</h3>
<p>Since you've created the HTML tags and JavaScript code for your UI, you can improve the look of it by styling it the way you like with CSS. For that, you can use the following code and add it inside the <code>style</code> tag in the HTML file.</p>
<pre><code class="language-css"> :root {
        color: #0b0b0f;
        font-family:
          "Inter",
          system-ui,
          -apple-system,
          sans-serif;
      }

      html,
      body {
        width: 100%;
        min-height: 100%;
        box-sizing: border-box;
      }

      body {
        margin: 0;
        padding: 16px;
        background: #f6f8fb;
      }

      main {
        width: 100%;
        max-width: 360px;
        min-height: 260px;
        margin: 0 auto;
        background: #fff;
        border-radius: 16px;
        padding: 20px;
        box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
      }

      h2 {
        margin: 0 0 16px;
        font-size: 1.25rem;
      }

      form {
        display: flex;
        gap: 8px;
        margin-bottom: 16px;
      }

      form input {
        flex: 1;
        padding: 10px 12px;
        border-radius: 10px;
        border: 1px solid #cad3e0;
        font-size: 0.95rem;
      }

      form button {
        border: none;
        border-radius: 10px;
        background: #111bf5;
        color: white;
        font-weight: 600;
        padding: 0 16px;
        cursor: pointer;
      }

      input[type="checkbox"] {
        accent-color: #111bf5;
      }

      ul {
        list-style: none;
        padding: 0;
        margin: 0;
        display: flex;
        flex-direction: column;
        gap: 8px;
      }

      li {
        background: #f2f4fb;
        border-radius: 12px;
        padding: 10px 14px;
        display: flex;
        align-items: center;
        gap: 10px;
      }

      li span {
        flex: 1;
      }

      li[data-completed="true"] span {
        text-decoration: line-through;
        color: #6c768a;
      }
</code></pre>
<p>Now you can commit your code to Git and track it in the tree:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add MCP server UI"</code></p>
</li>
</ul>
<h3 id="heading-step-4-add-the-ui-to-your-mcp-server">Step 4: Add the UI to your MCP Server:</h3>
<p>Writing the HTML file isn't enough to add resources to your project. You also need to upload the HTML and resources to the MCP server and configure the server to use them using the tools you provided.</p>
<p>To make your MCP server aware of the UI and HTML, you need to add extra functions to the MCP server and some _meta keys to the server tools.</p>
<p>Here's the signature of the resources function that the MCP server will use. This signature tells the LLM what type of file to read and which resources to use when it returns the output template. You'll add this code to your <a href="http://server.js">server.js</a> and your MCP server, then create your own HTML file that includes the design and UI.</p>
<pre><code class="language-javascript">registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource;
</code></pre>
<p>To use the signature function, you can use the following simple code at the top of your file, which will read the HTML file you created:</p>
<pre><code class="language-javascript">import { readFileSync } from "node:fs";

const todoHtml = readFileSync("public/todo-widget.html", "utf8");
</code></pre>
<p>And this resources registration code in the <code>createTodoServer</code> function, which will tell the LLM the type of HTML to use and where to find it.</p>
<pre><code class="language-javascript">server.registerResource(
    "todo-widget",
    "ui://widget/todo.html",
    {},
    async () =&gt; ({
        contents: [
            {
                uri: "ui://widget/todo.html",
                mimeType: "text/html+skybridge",
                text: todoHtml,
                _meta: { "openai/widgetPrefersBorder": true },
            },
        ],
    })
);
</code></pre>
<p>In the above code, you've added the following parameters:</p>
<ul>
<li><p>The name of the resource</p>
</li>
<li><p>The sources of the resource or the template as a string</p>
</li>
</ul>
<p>You kept the config empty to simplify the example</p>
<p>You only used the contents of the callback to show the information about the resources with the following details:</p>
<ul>
<li><p>mimeType: the type of the file you provided. You added Skybridge, which is the OpenAI protocol that renders the HTML inside an iframe in the ChatGPT UI.</p>
</li>
<li><p>URI: a specific name of your widget</p>
</li>
<li><p>Text: Which is your HTML file</p>
</li>
<li><p>_meta: specific details for ChatGPT</p>
</li>
</ul>
<h3 id="heading-step-5-update-your-mcp-server-to-handle-the-ui">Step 5: Update Your MCP Server to Handle the UI</h3>
<p>Now that you've written the HTML pages to show a simple UI for your data and added the HTML as a resource to your MCP server, you'll add the following code to the _meta section in the MCP server tools so it can handle and render the HTML output when needed. Without this, the LLM will only return the output without returning the UI:</p>
<pre><code class="language-javascript">_meta: {
            "openai/outputTemplate": "ui://widget/todo.html",
            "openai/toolInvocation/invoking": "Listing todos",
            "openai/toolInvocation/invoked": "Listed todos",
        },
</code></pre>
<p>So the _meta tag in your tools functions will look like the following:</p>
<pre><code class="language-javascript">    server.registerTool(
        'list_todos',
        {
            title: 'List todos',
            description: 'Lists all todo items.',
            _meta: {
                "openai/outputTemplate": "ui://widget/todo.html",
                'openai/toolInvocation/invoking': 'Listing todos',
                'openai/toolInvocation/invoked': 'Listed todos',
            },
        },
        async () =&gt; {
            return replyWithTodos();
        },
    );
</code></pre>
<p>Now you can commit your code to Git and track it in the tree:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add _meta outputTemplate tag to MCP server tools"</code></p>
</li>
</ul>
<h2 id="heading-how-to-test-your-chatgpt-app">How to Test Your ChatGPT App</h2>
<p>After adding the UI to your MCP server, you can run and test the project on ChatGPT by doing the following:</p>
<p>First, run your server normally with:</p>
<pre><code class="language-shell">node server.js
</code></pre>
<p>Then run your server through ngrok to enable online access, since you need OpenAI servers to be able to access your local machine:</p>
<pre><code class="language-shell">ngrok http 8787
</code></pre>
<p>Note: You need to have an ngrok account and log in to it via the CLI.</p>
<p>To add your resources to ChatGPT, you need to enable dev mode and add it as a connector:</p>
<ul>
<li><p>Click on your profile in the ChatGPT UI</p>
</li>
<li><p>Click on Apps</p>
</li>
<li><p>Click on Advanced settings to create your own app</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/cd78be7e-05e0-435e-86a6-9613b08f4e53.png" alt="Showing how to add a connector from ChatGPT Interface" style="display:block;margin:0 auto" width="1326" height="798" loading="lazy">

<p>Then you can add your server to ChatGPT and test it thoroughly.</p>
<p>You'll need to write the following data in this input:</p>
<ul>
<li><p>App name</p>
</li>
<li><p>Descripiton</p>
</li>
<li><p>Connection: as a server URL with your ngrok link from the terminal, with the <code>mcp</code> slash</p>
</li>
<li><p>No authentication, since we haven't implemented it yet</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/005ab434-a487-469a-9500-b4e64175e870.png" alt="the app input data for OpenAI" style="display:block;margin:0 auto" width="488" height="733" loading="lazy">

<p>After adding the app, you can use it in the conversation by calling it with the app name by writing <code>@app_name</code></p>
<p>Here are the examples from ChatGPT:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/0b36e741-e7c9-4d18-aa63-0fd419a68896.png" alt="Example of using the app inside ChatGPT" style="display:block;margin:0 auto" width="844" height="596" loading="lazy">

<p>Here is the example of completing a step:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/b19c338d-4b1b-4786-b514-40f0d586fbe5.png" alt="Example of completing a task inside the app in ChatGPT" style="display:block;margin:0 auto" width="846" height="788" loading="lazy">

<p>In the next section, you'll add authentication and a database to your project to move it to the next level.</p>
<h2 id="heading-level-3-how-to-add-supabase-auth-and-database-to-the-mcp-server">Level 3: How to Add Supabase (Auth and Database) to the MCP Server</h2>
<p>To add authentication and a backend, you'll need a backend/SQL server and an authentication server. The easiest current way is to use a service that can provide that. For this, you'll use Supabase.</p>
<p>To start, you'll create a new Supabase project for your backend. The project will include a simple table for the todos you have created in your MCP server and use it as the backend. Then you'll implement authentication.</p>
<h3 id="heading-step-1-create-the-todos-table">Step 1: Create the Todos Table</h3>
<p>To create the table, navigate through your project on Supabase and use the SQL editor to write the following code to add the todos:</p>
<pre><code class="language-sql">-- Enable pgcrypto for gen_random_uuid
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Create todos table
CREATE TABLE IF NOT EXISTS public.todos (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
  title text NOT NULL,
  completed boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

-- Function to keep updated_at current
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

-- Attach trigger
DROP TRIGGER IF EXISTS set_updated_at_trigger ON public.todos;

CREATE TRIGGER set_updated_at_trigger
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
</code></pre>
<p>At the end, set the table to row-level security. This allows related users to see their data:</p>
<pre><code class="language-sql">-- Enable Row Level Security
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;

-- Users can read only their own todos
CREATE POLICY "Users can view their own todos"
ON public.todos
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));

-- Users can insert only their own todos
CREATE POLICY "Users can insert their own todos"
ON public.todos
FOR INSERT
TO authenticated
WITH CHECK ((user_id IS NOT NULL) 
AND (user_id = (SELECT auth.uid())));

-- Users can update only their own todos
CREATE POLICY "Users can update their own todos"
ON public.todos
FOR UPDATE
TO authenticated 
USING (user_id = (SELECT auth.uid())) 
WITH CHECK (user_id = (SELECT auth.uid()));

-- Users can delete only their own todos
CREATE POLICY "Users can delete their own todos"
ON public.todos
FOR DELETE
USING (auth.uid() = user_id);

-- Index for faster user-specific queries
CREATE INDEX IF NOT EXISTS idx_todos_user_id
ON public.todos(user_id);
</code></pre>
<p>Since your database is now ready, you can integrate authentication with your server. First, you need to authenticate the server, get the token, and use it on the server. Then you can test the app again.</p>
<p>To authenticate, you need to implement the following endpoints on your server (and add your own information in place of the example info):</p>
<ul>
<li><p>GET: <a href="https://your-mcp.example.com/.well-known/oauth-protected-resource">https://your-mcp.example.com/.well-known/oauth-protected-resource</a></p>
</li>
<li><p>OAuth 2.0 metadata: <a href="https://auth.yourcompany.com/.well-known/oauth-authorization-server">https://auth.yourcompany.com/.well-known/oauth-authorization-server</a></p>
</li>
<li><p>OpenID Connect metadata: <a href="https://auth.yourcompany.com/.well-known/openid-configuration">https://auth.yourcompany.com/.well-known/openid-configuration</a></p>
</li>
</ul>
<p>The OAuth-protected resource communicates with the server about how to use and register the tools, how to run them, and what to call them. The other two endpoints share the related metadata from the server</p>
<p>You'll need to implement those endpoints on your server and use them as a proxy to fetch data from Supabase, since it will be your main auth server.</p>
<h3 id="heading-step-2-enabling-the-mcp-server-to-connect-with-supabase-auth">Step 2: Enabling the MCP Server to Connect with Supabase Auth</h3>
<p>For this, you need to do the following:</p>
<ul>
<li><p>Enable the OAuth server at Supabase and enable the dynamic registration of tools</p>
</li>
<li><p>Implement a page for login to use for OAuth permission</p>
</li>
</ul>
<p>To enable the OAuth server on your Supabase, you need to go to <a href="https://supabase.com/dashboard/project/_/auth/oauth-server">https://supabase.com/dashboard/project/_/auth/oauth-server</a>, then follow the next steps:</p>
<ul>
<li><p>Toggle Enable OAuth server</p>
</li>
<li><p>Allow dynamic apps</p>
</li>
<li><p>Create your consent page: the page that LLM tools will show users when they need to grant access to the data.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/136c2185-2d74-45f6-b981-2af946e1b330.png" alt="Showing how to enable dynamic apps from Supabase" style="display:block;margin:0 auto" width="1266" height="674" loading="lazy">

<p>To use the consent page and see it in action, you'll need to implement the OAuth server in your MCP server first. This is what you'll do in the next section.</p>
<h3 id="heading-step-3-create-a-proxy-server-for-the-mcp-server-to-handle-the-auth">Step 3: Create a Proxy Server for the MCP Server to Handle the Auth.</h3>
<p>After enabling the OAuth server in Supabase, you can start implementing the OAuth code on the MCP server. To do that, you need a proxy code on your MCP server and to create a logging endpoint to use it. The proxy server will allow your MCP server to use Supabase's OAuth server.</p>
<p>You'll continue by adding the next code to the MCP server you've created earlier. At the top of your code, after the imports in the <code>server.js</code> file, you should define the following variables:</p>
<pre><code class="language-javascript">const SUPABASE_URL = "https://YOURPORJECT.supabase.co";
const MCP_SERVER_URL = "http://localhost:8787/mcp";
const SUPABASE_AUTH_URL = `${SUPABASE_URL}/auth/v1`;
</code></pre>
<p>Note: Don't forget to enter your own project URL for the Supabase URL. You can find it in the Supabase UI by clicking Connect at the top of the page.</p>
<p>Inside the createServer function and after the <code>if (req.method === 'OPTIONS')</code> condition, add the following proxy code to link your Supabase project:</p>
<pre><code class="language-javascript">const OIDC_DISCOVERY_URL = `${SUPABASE_AUTH_URL}/.well-known/openid-configuration`;

if (req.method === "GET" &amp;&amp; url.pathname === "/.well-known/openid-configuration") {
    const response = await fetch(OIDC_DISCOVERY_URL);
    const data = await response.json();
    res.writeHead(200, {
        "content-type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, OPTIONS",
    });
    res.end(JSON.stringify(data));
    return;
}
</code></pre>
<p>Then you can add this code for the OAuth authorities server:</p>
<pre><code class="language-javascript">const OAUTH_DISCOVERY_URL = `${SUPABASE_URL}/.well-known/oauth-authorization-server/auth/v1`;

if (req.method === "GET" &amp;&amp; url.pathname === "/.well-known/oauth-authorization-server") {
    const response = await fetch(OAUTH_DISCOVERY_URL);
    const data = await response.json();
    res.writeHead(200, {
        "content-type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, OPTIONS",
    });
    res.end(JSON.stringify(data));
    return;
}
</code></pre>
<p>Then add this code for the well-known server:</p>
<pre><code class="language-javascript">// OPTIONS /.well-known/oauth-protected-resource/mcp
// GET /.well-known/oauth-protected-resource/mcp
if (req.method === "GET" &amp;&amp; (url.pathname === "/.well-known/oauth-protected-resource/mcp" || url.pathname === "/.well-known/oauth-protected-resource")) {
    const metadata = {
        resource: MCP_SERVER_URL,
        authorization_servers: [SUPABASE_AUTH_URL],
        // Use standard OIDC scopes. Custom resource scopes are enforced server-side, not by Supabase.
        scopes_supported: ["openid", "profile", "email", "phone"],
    };
    res.writeHead(200, {
        "content-type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, OPTIONS",
        "Access-Control-Allow-Headers": "content-type, MCP-Protocol-Version, mcp-protocol-version, authorization",
    });
    res.end(JSON.stringify(metadata));
    return;
}
</code></pre>
<p>By adding the previous code snippets, you've implemented a proxy server that fetches data from Supabase and relays it to the MCP protocol as if it were your own server.</p>
<p>Now you can commit your code to Git and track it in the tree:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add supabase proxy server"</code></p>
</li>
</ul>
<p>Since you've implemented your proxy server, you can use authentication and authorization from your MCP server to retrieve data in your tools.</p>
<h3 id="heading-step-4-implementing-the-consent-and-login-page">Step 4: Implementing the Consent and Login Page</h3>
<p>On the OAuth server, you might have noticed a consent page. The goal of this page is to inform the user that they are authorizing the LLM to connect to a database or an external resource. In the next section, you will implement this page by making two steps:</p>
<ul>
<li><p>First, create a login page that lets users log in to the app.</p>
</li>
<li><p>Second, you will create a consent page that allows the logged-in user to communicate with the LLM</p>
</li>
</ul>
<p>You'll start by creating a new Next.js server, which gives you more flexibility when working with pages.</p>
<p>You can create your NextJS app with the command:</p>
<pre><code class="language-shell">npx create-next-app@latest mcp_consent --yes
</code></pre>
<p>Navigate to the mcp_consent folder and add Supabase:</p>
<pre><code class="language-shell">npm install @supabase/ssr
</code></pre>
<p>Add the current state of the project to your Git tracker by writing the following:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "init nextjs project"</code></p>
</li>
</ul>
<p>Add <code>.env</code> file from your Supabase, which will include the following code:</p>
<pre><code class="language-plaintext">NEXT_PUBLIC_SUPABASE_URL=YOUR_URL
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=YOUR_KEY
</code></pre>
<p>Now you can create a login page in the next path:</p>
<p><code>app/login/page.tsx</code></p>
<p>The login page:</p>
<pre><code class="language-javascript">"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { createBrowserClient } from "@supabase/ssr/dist/module/createBrowserClient";


export default function LoginPage() {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState&lt;string | null&gt;(null);
    const router = useRouter();
    const searchParams = useSearchParams();
    const supabase = createBrowserClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
    );

    const handleLogin = async (e: React.FormEvent) =&gt; {
        e.preventDefault();
        setLoading(true);
        setError(null);


        try {
            const { error } = await supabase.auth.signInWithPassword({
                email,
                password,
            });


            if (error) {
                setError(error.message);
            } else {
                const redirectTo = searchParams.get("redirect") || "/";
                router.push(redirectTo);
                router.refresh();
            }
        } catch (err) {
            setError("An unexpected error occurred");
        } finally {
            setLoading(false);
        }
    };


    const handleSignUp = async (e: React.FormEvent) =&gt; {
        e.preventDefault();
        setLoading(true);
        setError(null);


        try {
            const { error } = await supabase.auth.signUp({
                email,
                password,
            });


            if (error) {
                setError(error.message);
            } else {
                setError(null);
                alert("Sign up successful! Please check your email to confirm your account.");
            }
        } catch (err) {
            setError("An unexpected error occurred");
        } finally {
            setLoading(false);
        }
    };


    return (
        &lt;div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"&gt;
            &lt;div className="max-w-md w-full bg-white rounded-lg shadow-md p-8"&gt;
                &lt;h2 className="text-center text-3xl font-extrabold text-gray-900 mb-8"&gt;
                    Authentication
                &lt;/h2&gt;


                {error &amp;&amp; (
                    &lt;div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded"&gt;
                        {error}
                    &lt;/div&gt;
                )}


                &lt;form onSubmit={handleLogin} className="space-y-6"&gt;
                    &lt;div&gt;
                        &lt;label
                            htmlFor="email"
                            className="block text-sm font-medium text-gray-700"
                        &gt;
                            Email address
                        &lt;/label&gt;
                        &lt;input
                            id="email"
                            type="email"
                            required
                            value={email}
                            onChange={(e) =&gt; setEmail(e.target.value)}
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-black"
                            placeholder="you@example.com"
                        /&gt;
                    &lt;/div&gt;


                    &lt;div&gt;
                        &lt;label
                            htmlFor="password"
                            className="block text-sm font-medium text-gray-700"
                        &gt;
                            Password
                        &lt;/label&gt;
                        &lt;input
                            id="password"
                            type="password"
                            required
                            value={password}
                            onChange={(e) =&gt; setPassword(e.target.value)}
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-black"
                            placeholder="••••••••"
                        /&gt;
                    &lt;/div&gt;


                    &lt;div className="flex gap-3"&gt;
                        &lt;button
                            type="submit"
                            disabled={loading}
                            className="flex-1 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
                        &gt;
                            {loading ? "Loading..." : "Login"}
                        &lt;/button&gt;
                        &lt;button
                            type="button"
                            onClick={handleSignUp}
                            disabled={loading}
                            className="flex-1 py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
                        &gt;
                            {loading ? "Loading..." : "Sign Up"}
                        &lt;/button&gt;
                    &lt;/div&gt;
                &lt;/form&gt;


                &lt;div className="mt-6"&gt;
                    &lt;p className="text-center text-sm text-gray-600"&gt;
                        Password reset or other options available upon request.
                    &lt;/p&gt;
                &lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;
    );
}
</code></pre>
<p>The OAuth decision page:</p>
<pre><code class="language-javascript">// app/api/oauth/decision/route.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
    const formData = await request.formData()
    const decision = formData.get('decision')
    const authorizationId = formData.get('authorization_id') as string
    if (!authorizationId) {
        return NextResponse.json({ error: 'Missing authorization_id' }, { status: 400 })
    }
    const supabase = createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
        {
            cookies: {
                getAll: async () =&gt; (await cookies()).getAll(),
                setAll: async (cookiesToSet) =&gt; {
                    const cookieStore = await cookies()
                    cookiesToSet.forEach(({ name, value, options }) =&gt; cookieStore.set(name, value, options))
                },
            },
        }
    )
    if (decision === 'approve') {
        const { data, error } = await supabase.auth.oauth.approveAuthorization(authorizationId)
        if (error) {
            return NextResponse.json({ error: error.message }, { status: 400 })
        }
        // Redirect back to the client with authorization code
        return NextResponse.redirect(data.redirect_url)
    } else {
        const { data, error } = await supabase.auth.oauth.denyAuthorization(authorizationId)
        if (error) {
            return NextResponse.json({ error: error.message }, { status: 400 })
        }
        // Redirect back to the client with error
        return NextResponse.redirect(data.redirect_url)
    }
}
</code></pre>
<p>The OAuth Consent page:</p>
<pre><code class="language-typescript">// app/oauth/consent/page.tsx
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export default async function ConsentPage({
    searchParams,
}: {
    searchParams: { authorization_id?: string }
}) {
    const authorizationId = (await searchParams).authorization_id

    if (!authorizationId) {
        return &lt;div&gt;Error: Missing authorization_id&lt;/div&gt;
    }

    const supabase = createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
        {
            cookies: {
                getAll: async () =&gt; (await cookies()).getAll(),
                setAll: async (cookiesToSet) =&gt; {
                    try {
                        const cookieStore = await cookies()
                        cookiesToSet.forEach(({ name, value, options }) =&gt;
                            cookieStore.set(name, value, options)
                        )
                    } catch (error) {
                        // In Server Components, cookie writes can fail during render.
                        // Route Handlers/Server Actions should handle persistence.
                        console.warn('Skipping cookie write in Server Component render context', error)
                    }
                },
            },
        }
    )

    // Check if user is authenticated
    const {
        data: { user },
    } = await supabase.auth.getUser()

    if (!user) {
        // Redirect to login, preserving authorization_id
        redirect(`/login?redirect=/oauth/consent?authorization_id=${authorizationId}`)
    }

    // Get authorization details using the authorization_id
    const { data: authDetails, error } =
        await supabase.auth.oauth.getAuthorizationDetails(authorizationId)
    console.log("Auth Details: ", authDetails)
    if (error || !authDetails) {
        return &lt;div&gt;Error: {error?.message || 'Invalid authorization request'}&lt;/div&gt;
    }
    if ("redirect_url" in authDetails &amp;&amp; authDetails.redirect_url &amp;&amp; typeof authDetails.redirect_url === "string") {
        const redirectUrl = authDetails.redirect_url;
        console.log("Redirect URL:", redirectUrl);
        return redirect(redirectUrl);
    }
    if (!("client" in authDetails)) {
        return &lt;div&gt;Error: Invalid authorization details format&lt;/div&gt;
    }
    return (
        &lt;div className="relative min-h-screen w-full overflow-hidden flex items-center justify-center p-4"&gt;
            {/* Animated gradient background */}
            &lt;div className="fixed inset-0 -z-10"&gt;
                &lt;div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-900 to-slate-800" /&gt;
                &lt;div className="absolute top-0 right-0 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl" /&gt;
                &lt;div className="absolute bottom-0 left-0 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" /&gt;
            &lt;/div&gt;

            {/* Main Card Container */}
            &lt;div className="w-full max-w-md animate-fade-in-up"&gt;
                {/* Gradient border effect */}
                &lt;div className="relative"&gt;
                    &lt;div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-purple-500 to-cyan-500 rounded-2xl blur opacity-75 group-hover:opacity-100 transition duration-1000" /&gt;

                    {/* Content Card */}
                    &lt;div className="relative bg-slate-900/80 backdrop-blur-xl rounded-2xl p-8 border border-slate-700/50 shadow-2xl"&gt;
                        {/* Header Section */}
                        &lt;div className="text-center mb-8"&gt;
                            &lt;div className="inline-block mb-4"&gt;
                                &lt;div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center shadow-lg"&gt;
                                    &lt;svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"&gt;
                                        &lt;path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /&gt;
                                    &lt;/svg&gt;
                                &lt;/div&gt;
                            &lt;/div&gt;
                            &lt;h1 className="text-3xl font-bold text-white mb-2"&gt;Authorization Required&lt;/h1&gt;
                            &lt;p className="text-slate-400 text-sm"&gt;Review and authorize access to your account&lt;/p&gt;
                        &lt;/div&gt;

                        {/* Client Information */}
                        &lt;div className="space-y-4 mb-8 bg-slate-800/50 rounded-lg p-4 border border-slate-700/30"&gt;
                            &lt;div className="flex items-start space-x-3"&gt;
                                &lt;div className="w-2 h-2 rounded-full bg-cyan-400 mt-2 flex-shrink-0" /&gt;
                                &lt;div className="flex-1"&gt;
                                    &lt;p className="text-xs text-slate-500 uppercase tracking-widest"&gt;Application&lt;/p&gt;
                                    &lt;p className="text-lg font-semibold text-white"&gt;{authDetails.client.name}&lt;/p&gt;
                                &lt;/div&gt;
                            &lt;/div&gt;

                            &lt;div className="flex items-start space-x-3"&gt;
                                &lt;div className="w-2 h-2 rounded-full bg-purple-400 mt-2 flex-shrink-0" /&gt;
                                &lt;div className="flex-1 min-w-0"&gt;
                                    &lt;p className="text-xs text-slate-500 uppercase tracking-widest"&gt;Redirect URI&lt;/p&gt;
                                    &lt;p className="text-xs text-slate-300 break-all font-mono mt-1"&gt;{authDetails.redirect_uri}&lt;/p&gt;
                                &lt;/div&gt;
                            &lt;/div&gt;
                        &lt;/div&gt;

                        {/* Permissions Section */}
                        {authDetails.scope &amp;&amp; authDetails.scope.length &gt; 0 &amp;&amp; (
                            &lt;div className="mb-8"&gt;
                                &lt;p className="text-xs text-slate-500 uppercase tracking-widest mb-3 font-semibold"&gt;Requested Permissions&lt;/p&gt;
                                &lt;div className="space-y-2"&gt;
                                    {authDetails.scope.split(" ").map((scope, index) =&gt; (
                                        &lt;div key={index} className="flex items-center space-x-2 text-sm text-slate-300 bg-slate-800/30 rounded-lg p-3 border border-slate-700/20"&gt;
                                            &lt;svg className="w-4 h-4 text-blue-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"&gt;
                                                &lt;path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /&gt;
                                            &lt;/svg&gt;
                                            &lt;span&gt;{scope}&lt;/span&gt;
                                        &lt;/div&gt;
                                    ))}
                                &lt;/div&gt;
                            &lt;/div&gt;
                        )}

                        {/* Action Buttons */}
                        &lt;form action="/api/oauth/decision" method="POST" className="space-y-3"&gt;
                            &lt;input type="hidden" name="authorization_id" value={authorizationId} /&gt;

                            &lt;button
                                type="submit"
                                name="decision"
                                value="approve"
                                className="w-full py-3 px-4 rounded-lg font-semibold text-white bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 shadow-lg hover:shadow-blue-500/50 transform hover:scale-105 transition-all duration-300 ease-out active:scale-95"
                            &gt;
                                Authorize Access
                            &lt;/button&gt;

                            &lt;button
                                type="submit"
                                name="decision"
                                value="deny"
                                className="w-full py-3 px-4 rounded-lg font-semibold text-slate-300 border-2 border-slate-600 hover:border-slate-500 hover:text-white hover:bg-slate-800/50 transition-all duration-300 ease-out active:scale-95"
                            &gt;
                                Cancel
                            &lt;/button&gt;
                        &lt;/form&gt;

                        {/* Security Info */}
                        &lt;p className="text-center text-xs text-slate-500 mt-6 flex items-center justify-center space-x-1"&gt;
                            &lt;svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"&gt;
                                &lt;path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" /&gt;
                            &lt;/svg&gt;
                            &lt;span&gt;Your data is protected with industry-standard encryption&lt;/span&gt;
                        &lt;/p&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;
    )
}
</code></pre>
<p>Now you can add the current state of the project to your Git tracker by writing the following:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add consent page"</code></p>
</li>
</ul>
<h3 id="heading-step-5-testing-the-oauth-implementation-with-mcp-server-inspector">Step 5: Testing the OAuth Implementation with MCP Server Inspector</h3>
<p>Since you've implemented the consent page, now you can test it and check the authorization in the MCP Server Inspector. This step will help you see how OAuth works and how to test it with the inspector.</p>
<p>First, create a new user in Supabase for login and authentication.</p>
<ul>
<li><p>Go to: <a href="https://supabase.com/dashboard/project/_/auth/users">https://supabase.com/dashboard/project/_/auth/users</a><br>(Auth -&gt; users from the UI)</p>
</li>
<li><p>Click Add User -&gt; Create a new user, then add the new user email and password.</p>
</li>
</ul>
<p>After creating the user, you can run the projects by typing the following in different terminals:</p>
<p>Run your MCP server:</p>
<pre><code class="language-plaintext">node server.js
</code></pre>
<p>Open your inspector:</p>
<pre><code class="language-plaintext">npx @modelcontextprotocol/inspector@latest --server-url http://localhost:8787/mcp --transport http
</code></pre>
<p>Run the Next.js project to get access to the consent page:</p>
<pre><code class="language-plaintext">cd mcp_consent
npm run dev
</code></pre>
<p>When you run the inspector, you can connect to your MCP Server on the left and navigate to Auth on the top tab. This will show you the authentication flow to test and run.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/f06db9f1-bdfa-4757-8177-21651c291666.png" alt="OAuth Flow inside the MCP Server Inspector" style="display:block;margin:0 auto" width="1510" height="680" loading="lazy">

<p>When you click Connect, go to Auth to check your options. The guided OAuth flow will show you a step-by-step guide to how the MCP Server obtains OAuth authorization and will help you debug your code if issues arise. The Check OAuth Flow button lets you connect directly and see the latest result immediately.</p>
<p>For the sake of speed, you can just click on the "Check OAuth Flow". This will redirect you to the login page:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/3a7c5090-41d2-489f-b46b-5554ebcfc149.png" alt="Login page screenshot" style="display:block;margin:0 auto" width="561" height="487" loading="lazy">

<p>After you log in, you'll get redirected again to the consent page so that you can give consent to the LLM to access your data:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/c9caa39b-4750-43ef-9c77-603898914e90.png" alt="Consent page screenshot" style="display:block;margin:0 auto" width="550" height="904" loading="lazy">

<p>Then you'll be redirected again to the MCP Server and you can check the results of the OAuth flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/e43e2096-901e-4669-b965-0019810704b4.png" alt="Correct MCP Server OAuth flow" style="display:block;margin:0 auto" width="256" height="611" loading="lazy">

<p>In the next step, you'll harden your MCP Server functions to take your app to the next level by using OAuth for MCP Server.</p>
<h3 id="heading-step-6-adding-oauth-security-to-your-mcp-server-tools">Step 6: Adding OAuth Security to Your MCP Server Tools</h3>
<p>Congrats on implementing your OAuth flow and getting it to work! Now you'll add this flow to your MCP Server tools so it runs only when the user is authenticated.</p>
<p>Before updating the tools, you'll write a few helper functions to assist you during the process. First, you'll write a verification token to process every request. Then you'll update the list of MCP server tools to use the verification function instead of implementing it for each function by itself.</p>
<p>Inside your <code>server.js</code> file, you'll implement a function that verifies the token with Supabase. First, import the Supabase client to use it:</p>
<pre><code class="language-javascript">import { SupabaseClient } from "@supabase/supabase-js";
</code></pre>
<p>Then add the Supabase publishable key to use it in the client at the top of the server:</p>
<pre><code class="language-javascript">const SUPABASE_PUBLISHABLE_KEY = "YOUR_KEY";
</code></pre>
<p>And update the reply todos list to get an argument of todos, instead of the in-memory array.</p>
<pre><code class="language-javascript">const replyWithTodos = (message, todos) =&gt; ({
    content: message ? [{ type: 'text', text: message }] : [],
    structuredContent: { tasks: todos },
}); //outside the createTodoServer function block
</code></pre>
<p>Then you'll need to create a helper function to verify the user tokens:</p>
<pre><code class="language-javascript">const verifyToken = async (token) =&gt; {
    if (!token || !token.startsWith("Bearer ")) {
        return { isValid: false, error: "Missing or invalid Authorization header" };
    }
    // Verify token with Supabase
    try {
        // use supabase client to verify token
        const supabase = new SupabaseClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
            global: {
                headers: {
                    Authorization: token,
                },
            },
        });
        const { data: user, error } = await supabase.auth.getUser();
        if (error || !user) {
            return { isValid: false, error: "Token verification failed" + (error?.message || "") };
        }
        console.log("Token verified for user:", user);
        return { isValid: true, token, user, supabase };
    } catch (error) {
        console.error("Token verification failed:", error);
        return { isValid: false, error: "Token verification failed" + (error?.message || "") };
    }
};
</code></pre>
<p>In this function, you get the token as a string, check Supabase, and return an error if the token isn't provided. If it's correct, you return the token, user data, and Supabase client.</p>
<p>After this, you need to have a helper function to adhere to MCP Server specs:</p>
<pre><code class="language-javascript">/**
 * Build WWW-Authenticate header for 401/403 responses
 * Per RFC 9728 OAuth 2.1 Protected Resource Metadata specification
 */
function buildWwwAuthenticateHeader(error, errorDescription) {
    const resourceMetadataUrl = `${MCP_SERVER_URL}/.well-known/oauth-protected-resource`

    let header = `Bearer resource_metadata="${resourceMetadataUrl}"`

    if (error) {
        header += `, error="${error}"`
    }

    if (errorDescription) {
        header += `, error_description="${errorDescription}"`
    }

    return header
}

function returnAuthErrorResponse(resOrMessage, error = "unauthorized", errorDescription = "Missing or invalid authorization token.") {
    const wwwAuthenticate = buildWwwAuthenticateHeader(error, errorDescription);

    if (resOrMessage &amp;&amp; typeof resOrMessage.writeHead === "function") {
        resOrMessage.writeHead(401, {
            "content-type": "application/json",
            "Access-Control-Allow-Origin": "*",
            "WWW-Authenticate": wwwAuthenticate,
        });
        resOrMessage.end(JSON.stringify({ error, error_description: errorDescription }));
        return;
    }

    const message = typeof resOrMessage === "string" &amp;&amp; resOrMessage.length &gt; 0
        ? resOrMessage
        : errorDescription;

    return {
        content: [{ type: "text", text: message }],
        isError: true,
        statusCode: 401,
        _meta: {
            "mcp/www_authenticate": wwwAuthenticate,
        },
    };
}


function returnErrorResponse(message) {
    return {
        content: [
            {
                type: "text",
                text: message
            }
        ],
        isError: true
    };
}
</code></pre>
<p>In these helper functions, you create unique functions for errors and the OAuth error return function. At the same time, you define the OAuth-protected resources discovery specs.</p>
<p>Add the current state of the project to your Git tracker by writing the following:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: add helper functions"</code></p>
</li>
</ul>
<p>Now you can apply them to your MCP Server tools, making them easier to read.</p>
<h3 id="heading-step-7-updating-the-mcp-server-function-to-handle-the-authentication">Step 7: Updating the MCP Server Function to Handle the Authentication</h3>
<p>After you've built the proxy to handle authentication requests, you need to update the MCP server functions and metadata to indicate whether the tool can be used with or without authentication.</p>
<p>Here you'll add two main things:</p>
<ul>
<li><p>The security schema.</p>
</li>
<li><p>The logic for the function to handle.</p>
</li>
</ul>
<pre><code class="language-javascript">   server.registerTool(
        "list_todos",
        {
            title: "List todos",
            description: "Lists all todo items.",
            _meta: {
                "openai/outputTemplate": "ui://widget/todo.html",
                "openai/toolInvocation/invoking": "Listing todos",
                "openai/toolInvocation/invoked": "Listed todos",
            },
            securitySchemes: [
                { type: "oauth2", scopes: ["todos.read"] }
            ],
            "annotations": {
                "readOnlyHint": true,
                "openWorldHint": false,
                "destructiveHint": false,
            }
        },
        async (meta) =&gt; {
            const authHeader = meta.requestInfo.headers?.authorization;
            const authResult = await verifyToken(authHeader);
            if (!authResult?.isValid) {
                return returnAuthErrorResponse(authResult?.error);
            }
            const { data, error } = await authResult.supabase
                .from("todos")
                .select("*")
                .eq("user_id", authResult.user.user.id)
                .order("created_at", { ascending: false });
            if (error) {
                console.error("Error listing todos:", error);
                return returnErrorResponse(error.message);
            }
            return replyWithTodos(null, data ?? []);
        }
    )
</code></pre>
<p>In this code, you've done the following:</p>
<ul>
<li><p>Updated the metadata to have a security schema that tells the MCP server to request authentication when invoking these tools.</p>
</li>
<li><p>Added an annotation, which helps the LLM model know how this function will perform. The annotations declare three types of changes that the tool can make:</p>
<ul>
<li><p>Read Only Hint: tells the LLM whether the tool is read-only and only shows data</p>
</li>
<li><p>Open World Hint: tells the LLM whether the tool can access external data, websites, or the internet.</p>
</li>
<li><p>Destructive Hint: tells the LLM if this is a destructive function, like deleting data permanently for the user</p>
</li>
</ul>
</li>
<li><p>Then, in the function itself, you retrieved the metadata from the callback and verified the token using the Supabase helper function. After that, you used the basic Supabase functions to retrieve the data and any errors that might occur.</p>
</li>
</ul>
<p>You can get the authorization from the metadata in the callback function itself.</p>
<p>In the previous function, you used the Supabase client to access the todos table, select all columns where the user_id condition is met, and order them by creation time. If the Supabase client returns an error, you return an error. Here's the code snippet that relates to Supabase:</p>
<pre><code class="language-javascript">const { data, error } = await authResult.supabase
  .from("todos")
  .select("*")
  .eq("user_id", authResult.user.user.id)
  .order("created_at", { ascending: false });
if (error) {
  console.error("Error listing todos:", error);
  return returnErrorResponse(error.message);
}
</code></pre>
<p>For the other function, you have the same logic applied, yet instead of select, you'll use either <code>insert</code> or <code>update</code>.</p>
<p>For the other functions, you can follow the same principles and use the same code. The only change is that first you get the data, then the metadata in the callback function:</p>
<pre><code class="language-javascript">    server.registerTool(
        "add_todo",
        {
            title: "Add todo",
            description: "Creates a todo item with the given title.",
            inputSchema: addTodoInputSchema,
            _meta: {
                "openai/outputTemplate": "ui://widget/todo.html",
                "openai/toolInvocation/invoking": "Adding todo",
                "openai/toolInvocation/invoked": "Added todo",
            },
            securitySchemes: [
                { type: "oauth2", scopes: ["todos.write"] }
            ],
            "annotations": {
                "readOnlyHint": false,
                "openWorldHint": false,
                "destructiveHint": true,
            }
        },
        async (args, meta) =&gt; {
            const authorizationHeader = meta.requestInfo.headers?.authorization;
            console.log("Authorization header:", authorizationHeader);
            const authResult = await verifyToken(authorizationHeader);
            console.log("Auth result:", authResult);
            if (!authResult?.isValid) {
                return returnAuthErrorResponse(authResult?.error);
            }
            const title = args?.title?.trim?.() ?? "";
            if (!title) return returnErrorResponse("Missing title.");
            let { data, error } = await authResult.supabase
                .from("todos")
                .insert({ title, user_id: authResult.user.user.id })
                .select("*");
            if (error) {
                console.error("Error adding todo:", error);
                return returnErrorResponse(error.message);
            }
            return replyWithTodos(`"${title}"`, data);
        }
    );
</code></pre>
<p>Here's the updated function code:</p>
<pre><code class="language-javascript">    server.registerTool(
        "complete_todo",
        {
            title: "Complete todo",
            description: "Marks a todo as done by id.",
            inputSchema: completeTodoInputSchema,
            _meta: {
                "openai/outputTemplate": "ui://widget/todo.html",
                "openai/toolInvocation/invoking": "Completing todo",
                "openai/toolInvocation/invoked": "Completed todo",
            },
            securitySchemes: [
                { type: "oauth2", scopes: ["todos.write"] }
            ],
            "annotations": {
                "readOnlyHint": false,
                "openWorldHint": false,
                "destructiveHint": true,
            }
        },
        async (args, meta) =&gt; {
            const authorizationHeader = meta.requestInfo.headers?.authorization;
            const authResult = await verifyToken(authorizationHeader);
            if (!authResult?.isValid) {
                return returnAuthErrorResponse(authResult?.error);
            }
            const id = args?.id;
            if (!id) return replyWithTodos("Missing todo id.");
            const { data, error } = await authResult.supabase
                .from("todos")
                .update({ completed: true })
                .eq("id", id)
                .eq("user_id", authResult.user.user.id)
                .select("*");
            if (error) {
                console.error("Error completing todo:", error);
                return returnErrorResponse(error.message);
            }
            if (!data || data.length === 0) {
                return replyWithTodos(`Todo ${id} was not found.`);
            }
            return replyWithTodos(`Completed "${data[0].title}".`, data);
        }
    );
</code></pre>
<p>By applying this, you already have a fully functioning MCP server connected to your Supabase, and you can rely on it to run.</p>
<p>Add the current state of the project to your Git tracker by writing the following:</p>
<ul>
<li><p><code>git add .</code></p>
</li>
<li><p><code>git commit -m "feat: update tools to use database"</code></p>
</li>
</ul>
<h3 id="heading-step-8-testing-the-server-with-supabase">Step 8: Testing the Server with Supabase:</h3>
<p>To do this, you can follow the same steps as in "Testing the OAuth implementation with MCP Server Inspector." But as an extra point, keep an eye on your database table in the Supabase UI, where you can see the added and updated todos. Then you can check the tools, test your todos, and even use ngrok to test them in the ChatGPT UI.</p>
<h2 id="heading-how-to-deploy-your-mcp-server-to-digitalocean">How to Deploy your MCP Server to DigitalOcean</h2>
<p>Since you have your MCP server running and working well, you can now deploy it to DigitalOcean using their App service.</p>
<p>First, upload your code to GitHub and commit it with the following command:</p>
<pre><code class="language-shell">gh repo create todo_mcp_server --private --source=. --remote=upstream

git push
</code></pre>
<p>This command creates a new repo on GitHub, sets your stream to GitHub, and pushes the current branches to GitHub.</p>
<p>Then log in to your DigitalOcean account and go to Apps (<a href="https://cloud.digitalocean.com/apps">https://cloud.digitalocean.com/apps)</a>.</p>
<p>Click on Create app:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/b306ee0f-ccd1-4d3c-b928-cadb0c78d760.png" alt="b306ee0f-ccd1-4d3c-b928-cadb0c78d760" style="display:block;margin:0 auto" width="720" height="303" loading="lazy">

<p>Choose the source as GitHub:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/e6a3c368-d5e4-4529-8e98-22023ca285df.png" alt="Digital Ocean showing how to get a repo from GitHub" style="display:block;margin:0 auto" width="915" height="827" loading="lazy">

<p>Then you need to select your repository and your branch. Write the source directories as: <code>/</code> and <code>mcp_consent</code>. You're doing this because you'll be running two apps: the MCP Server and the consent and login page frontend.</p>
<p>Next, enable auto-deploy if you want the app to update whenever you push your code to GitHub:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/a16462f9-41fb-4412-885e-f91eb6b4958a.png" alt="a16462f9-41fb-4412-885e-f91eb6b4958a" style="display:block;margin:0 auto" width="843" height="801" loading="lazy">

<p>Since we've created two source directories, you'll have two apps and will have to manage them separately.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/71bc8d47-2098-41ca-b168-aba6a3a5e3a9.png" alt="71bc8d47-2098-41ca-b168-aba6a3a5e3a9" style="display:block;margin:0 auto" width="625" height="843" loading="lazy">

<p>You'll use the MCP server in the first app and the frontend for the second app. For that reason, you'll update the network to have the server under the <code>/server</code> route:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/2b58cf42-6ed2-4c91-9395-f3114060d2a9.png" alt="2b58cf42-6ed2-4c91-9395-f3114060d2a9" style="display:block;margin:0 auto" width="630" height="602" loading="lazy">

<p>And you'll downsize the CPU to minimize the cost for this demo:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/d7a57963-7b4d-415b-bd8a-e02ea14b044e.png" alt="d7a57963-7b4d-415b-bd8a-e02ea14b044e" style="display:block;margin:0 auto" width="609" height="636" loading="lazy">

<p>You can update the size later based on your needs for the app.</p>
<p>As for the last step here, you'll update the run command to <code>node server.js</code> to ensure the app is running correctly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/8a8c0357-bd33-445a-a25a-cb90a3682bdd.png" alt="8a8c0357-bd33-445a-a25a-cb90a3682bdd" style="display:block;margin:0 auto" width="623" height="608" loading="lazy">

<p>For the frontend project, you'll have to click on the second app, update the inputs as well, and add the environment variables:</p>
<p>First, you can downsize the app:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/f82e0305-d3a2-4fc7-bc14-08d6c34a7174.png" alt="f82e0305-d3a2-4fc7-bc14-08d6c34a7174" style="display:block;margin:0 auto" width="618" height="660" loading="lazy">

<p>Update the build and run commands for the Next js server:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/1341f752-85e3-4e94-b816-b3c1413a7b73.png" alt="1341f752-85e3-4e94-b816-b3c1413a7b73" style="display:block;margin:0 auto" width="616" height="615" loading="lazy">

<p>Then you can check the route of this web app and set it as the main one:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/2f4e4e0f-20bf-46c8-ba1e-80ccb9c11167.png" alt="2f4e4e0f-20bf-46c8-ba1e-80ccb9c11167" style="display:block;margin:0 auto" width="626" height="503" loading="lazy">

<p>At the end, you need to add the <code>.env</code> variables from your <code>.env</code> file to the project. You can copy and paste them directly from your <code>mcp_const/.env</code> file to the project.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/4dd9ac38-ed69-4c0d-b5da-eb2dfef41be8.png" alt="4dd9ac38-ed69-4c0d-b5da-eb2dfef41be8" style="display:block;margin:0 auto" width="615" height="623" loading="lazy">

<p>After setting them up, you can create and run the app, which will generate a public URL from DigitalOcean that you can use in the ChatGPT UI again to test it and run the project.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e02a4159bd66d90e61a22c/ef889fa2-bdd5-47ed-84e7-816cb0c71a30.png" alt="Showing how to copy the link from Digital Ocean" style="display:block;margin:0 auto" width="1258" height="714" loading="lazy">

<p>Before adding the project to test it in ChatGPT, you need to update the consent page URL in Supabase <a href="https://supabase.com/dashboard/project/_/auth/url-configuration">from here</a>.</p>
<p>Instead of having<code>localhost:300</code>, you can add the link from your DigitalOcean account.</p>
<p>At this point, you can test the server with your DigitalOcean by using the following links:</p>
<ul>
<li><p>YOUR_DIGITAL_OCEAN.com/server/mcp</p>
</li>
<li><p>YOUR_DIGITAL_OCEAN.com/login</p>
</li>
<li><p>YOUR_DIGITAL_OCEAN.com/oauth/consent</p>
</li>
</ul>
<h2 id="heading-how-to-publish-your-chatgpt-app">How to Publish Your ChatGPT App</h2>
<p>After running your app, you need to host it. You can simply upload it to GitHub and host it on DigitalOcean as a JavaScript app. You can get the URL from DigitalOcean, then go to your OpenAI <a href="https://platform.openai.com/apps-manage">dashboard</a>, verify yourself as a company or a solo developer, and upload the file there.</p>
<p>To publish your app to ChatGPT, you'll need to provide the following information:</p>
<ul>
<li><p><strong>App Info:</strong> the basic information about your app, including the logo, description, a video demo, website, support, privacy policy, and terms of service URLs (plus a few more details about monetizing your app if you have done that).</p>
</li>
<li><p><strong>MCP Server:</strong> the links to your MCP server, the tools you have, and how you'll use them, plus a verification token for your URL that you'll need to add to your project as a path.</p>
</li>
<li><p><strong>Testing:</strong> you'll need to provide at least 5 test cases for your MCP server so OpenAI can test its functionality. They require you to have coverage over all the major use cases that you intend to support and include all information required to successfully run the test case.</p>
<p>In the tests you share:</p>
<ul>
<li><p>Scenario: where you describe the use case to test (for example, “Research flights”, “Create a slideshow”, “Find a hiking trail”).</p>
</li>
<li><p>User prompt: The exact prompt or interaction you should conduct to begin the test.</p>
</li>
<li><p>Tool triggered: Which tools should be called? You have already implemented them.</p>
</li>
<li><p>Expected output: The output or experience you should expect to receive back from the MCP server.</p>
</li>
<li><p>Then you share the negative cases with the same examples.</p>
</li>
</ul>
</li>
<li><p><strong>Screenshots:</strong>&nbsp;App screenshots for the directory. You can use this public&nbsp;<a href="https://www.figma.com/design/SIiC9BoS6Jkr2oz9JoGFlt/-Public--ChatGPT-Apps---Screenshots?node-id=0-1&amp;p=f&amp;t=npK72eKLrTmXAiZ0-0">Figma</a>&nbsp;to help you with your design. Here, you should upload 1–4 screenshots of your app widget UI in PNG or JPG format, each with a width of 706px and a height of 400–860px (at least one must be 2× retina quality). The first three screenshots are publicly visible in install views across all screen sizes and locales. Ensure the images show only your widget UI – no ChatGPT interface, user prompts, model responses, or embedded text.</p>
</li>
<li><p><strong>Global:</strong> shows the text and localization for your app. You can also select specific countries to publish to.</p>
</li>
<li><p><strong>Submit:</strong> This requires you to write the release notes and run a few compliance checks on your app.</p>
</li>
</ul>
<p>After uploading and updating your project, you can wait for OpenAI to review your project and get the results from them.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>You now have the basic knowledge you need to explore MCP servers and ChatGPT apps. You can dive deeper by reading the documentation and checking the related tools and platforms for building apps like&nbsp;<a href="https://github.com/alpic-ai/skybridge">Skybridge</a>.</p>
<p>If you liked this tutorial, you can follow me on <a href="https://twitter.com/a0m0rajab">Twitter</a> or <a href="https://www.youtube.com/@hadithtech/live">YouTube</a> and run the full project demo script on <a href="https://github.com/a0m0rajab/OpenAi_MCP_Supabase">GitHub</a>.</p>
<h2 id="heading-acknowledgments">Acknowledgments:</h2>
<p>Thanks to <a href="https://www.linkedin.com/in/ahmedmukbilsaleh/">Ahmed Saleh</a> for supporting me with the ChatGPT Apps concept, Abbey from freeCodeCamp for her patience during the editorial process, and the Supabase and OpenAI teams for their awesome work and documentation!</p>
<h2 id="heading-references">References:</h2>
<p>This blog would not have been written without the hard work of the OpenAI, Supabase, and DigitalOcean teams that they put into the following documentation:</p>
<ul>
<li><p><a href="https://docs.digitalocean.com/products/app-platform/how-to/deploy-from-monorepo/">How to Deploy from Monorepos (DigitalOcean)</a></p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk">OpenAI Apps SDK Documentation</a></p>
</li>
<li><p><a href="https://supabase.com/docs/guides/auth/oauth-server/getting-started?queryGroups=oauth-setup&amp;oauth-setup=programmatically">Supabase OAuth 2.1 Server Documentation</a></p>
</li>
<li><p><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">Model Context Protocol Authorization</a></p>
</li>
<li><p><a href="https://github.com/Rodriguespn/mcp-auth-edge">Supabase MCP Demo by Pedro Rodrigues</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Reverse Engineer a Website – a Guide for Developers ]]>
                </title>
                <description>
                    <![CDATA[ While using one of your favorite websites, you might have often thought, "What if this website had this particular functionality? That would be great!" If you have ever had such thoughts, this article is for you. In it, you'll learn how websites comm... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-reverse-engineer-a-website/</link>
                <guid isPermaLink="false">6734cea5ae439f84c4b2399b</guid>
                
                    <category>
                        <![CDATA[ reverse engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Programming Blogs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abdurrahman Rajab ]]>
                </dc:creator>
                <pubDate>Wed, 13 Nov 2024 16:07:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/FlPc9_VocJ4/upload/bfbb3d6320985e7e7db8d18465d20af4.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>While using one of your favorite websites, you might have often thought, "What if this website had this particular functionality? That would be great!"</p>
<p>If you have ever had such thoughts, this article is for you. In it, you'll learn how websites communicate with servers and get data and how to work backwards to understand how that website functions.</p>
<p>You will also see how to add functionality to a website or use its APIs to recreate it yourself. You will use a simple demo website in the article to do that. The website contains some sales data grabbed by a remote API. In the demo, you will use the website to see what APIs have been used to get the data and how to use the sales data API.</p>
<p>If you understand how to access the data API on this website, you can use the same methodology to access this data on any website you like.</p>
<h2 id="heading-prerequisites">Prerequisites:</h2>
<p>This article should be accessible to anyone who knows the basics of programming. You will see examples in JavaScript, but you can use these techniques in your favorite language. Having some basic knowledge of how the web works will also be helpful.</p>
<p>You need to install the project from <a target="_blank" href="https://github.com/a0m0rajab/reverse-engineering-lab">GitHub</a> and run it to experiment with this tutorial.</p>
<h2 id="heading-what-well-cover">What We’ll Cover:</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites:</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-an-api">What is an API?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-do-apis-work">How Do APIs Work?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-reverse-engineering">What is Reverse Engineering?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-reverse-engineer-a-website">How to Reverse Engineer a Website</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-explore-the-website">Explore the Website</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-check-the-sales">Check the Sales</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-use-the-api-on-your-website">How to Use the API on Your Website</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-get-the-authorization-and-explore-it-through-the-website">How to Get the Authorization and Explore it Through the Website</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-next-steps-authorization-and-authentication">Next Steps: Authorization and Authentication</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-what-is-an-api">What is an API?</h2>
<p>Application programming interfaces (APIs) allow two computer programs to communicate. You request the data you want to use in your project from an API, and the API fetches it for you.</p>
<p>These APIs can be local (like Windows APIs, Web APIs, and so on) or remote (like the APIs that developers provide through the internet, such as the Weather API and website APIs).</p>
<p>This article will focus on remote APIs, since developers often use this approach on modern websites. Websites use APIs to display results based on a response.</p>
<p>Some companies might provide access to their APIs so you can develop on top of them, but this is only the case for some. Sometimes, an API might need to provide the functionality or design you want. So, first, you should look at what a site offers and use it to create the features you want.</p>
<p>In this tutorial, you will learn how to understand and explore the APIs behind a website so you can use them in your projects. You will first learn how APIs work, then explore what reverse engineering means. Then, you'll use a demo website and an example through Postman, where you will use an API to get some data from the website. You'll be able to use this data anywhere you want.</p>
<h2 id="heading-how-do-apis-work">How Do APIs Work?</h2>
<p>The structure of an API contains two levels: the client and the server. The client requests data from the API, and the server provides it. This technology has been around for a long time and is now standardized.</p>
<p>The client starts to request the data by connecting with the correct endpoint and providing the related information for the server. The server checks this data, and based on that, it does its magic and returns a response to the client about which process to use.</p>
<p>Here is a simple drawing showing this process:</p>
<p><img src="https://voyager.postman.com/illustration/diagram-what-is-an-api-postman-illustration.svg" alt="An explanation of APIs by the Postman team (From: What is an API? A Beginner's Guide to APIs | Postman)" width="628" height="386" loading="lazy"></p>
<p>API requests and responses usually have a similar structure, which is:</p>
<p>Request:</p>
<ul>
<li><p>Endpoints: the target URL of the API.</p>
</li>
<li><p>Methods: tell the server what to do with data, like get the data, update it, delete it, and so on.</p>
</li>
<li><p>Parameters: extra details you provide to the server for additional requests, like the topic, category, and so on.</p>
</li>
<li><p>Headers: these key-value pairs provide information about the client, authentication, and more.</p>
</li>
<li><p>Body: this is the actual data provider, which includes whatever the client wants from the server.</p>
</li>
</ul>
<p>Response:</p>
<ul>
<li><p>Status code: this three-digit HTTP status code tells the client about the server's result.</p>
</li>
<li><p>Header: this is similar to the request but has the server's information. It could be setting cookies or other details.</p>
</li>
<li><p>Body: has the actual data from the server to use.</p>
</li>
</ul>
<p>Now that you know a bit about APIs and HTTP requests, you can reverse engineer a website.</p>
<h2 id="heading-what-is-reverse-engineering">What is Reverse Engineering?</h2>
<p>Reverse engineering is the art of analyzing a system to understand how developers built it. It helps you figure out how it functions so you can improve or hack it.</p>
<p>Some people use reverse engineering to crack programs. Others use it to customize them or even add extra functionalities.</p>
<p>As for websites, the reverse engineering process will help you understand what APIs a site has and how it's using them. It enables you to write your program based on the site's APIs.</p>
<p>Sometimes, reverse engineering can be used to find bugs, crack software, or even use an API without permission. Website developers tend to prevent that by providing an official API for their website, setting limits for API usage, and detecting any unauthorized use.</p>
<p>For this reason, when you start to reverse engineer any program, you will need to consider the terms of use and the legal side of your work so that you’re not doing anything illegal or unethical.</p>
<h2 id="heading-how-to-reverse-engineer-a-website">How to Reverse Engineer a Website</h2>
<p>To reverse engineer a website, you need to do two things: first, you need to explore the website to see how it works and learn what kind of data and endpoints it provides. Second, you need to set some assumptions about how it works and try to validate the assumptions.</p>
<p>A simple assumption would be that after logging into the website, the website receives authentication information through API requests. Getting this information will allow you to use the website APIs without the need to log in every time.</p>
<p>To validate this assumption, you'll need to investigate the requests sent and received by the website. Then you'll need to send your requests by yourself from an external source like your terminal through CURL or HTTP client like Postman.</p>
<p>I have created a demo website that we'll reverse engineer. You'll run the website on your computer and then reverse-engineer it. The website shows you a simple login page and has some customer data. Your goal will be to get the customer's recent sales data.</p>
<p>Here are a couple screenshots of the website and what you have:</p>
<p>Page 1: Login:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731438727536/cfb07e6b-2416-41c7-a24e-1cf52b1943b0.png" alt="Screenshot of the login page" class="image--center mx-auto" width="827" height="813" loading="lazy"></p>
<p>Page 2: The website data</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258198260/8c96ed82-a288-4141-b97d-e97e71e6412e.png" alt="Screenshot of the data page" class="image--center mx-auto" width="1891" height="740" loading="lazy"></p>
<h3 id="heading-explore-the-website">Explore the Website</h3>
<p>The first step in reverse engineering is to explore the website and see how it works. To do this, you'll use Chrome developer tools to check the requests sent by the website and see how they affect it. You'll also look for data received by those requests and see how you can use them.</p>
<p>At the same time you'll need to filter the requests since some of them don't send or receive data, but they get various files that the website uses, like CSS files or images.</p>
<p>Chrome developer tools help you analyze and understand a website, showing you the HTML elements, network, and storage that the website uses.</p>
<h3 id="heading-check-the-sales">Check the Sales</h3>
<p>Your target is to check the website's sales, so you need to log in to access the website and go to the dashboard page to check the sales.</p>
<p>On the sales page, you will do the following:</p>
<p>First, open Chrome dev tools (by either clicking on F12 or right-clicking anywhere, then opening Inspector) to see what kind of APIs the website provides.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258258531/1e50c70c-1b97-49a4-aed5-a821c56d638d.png" alt="Chrome right-click menu" class="image--center mx-auto" width="750" height="717" loading="lazy"></p>
<p>Then, after opening the dev tools, you need to go to the network tab to check the network requests and see what the website sends to the server.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258501092/3c0e3879-981b-48e2-95a9-736212152938.png" alt="Network tab in the Chrome developer tools" class="image--center mx-auto" width="788" height="375" loading="lazy"></p>
<p>The network tab shows you the requests sent from the website to the server and how the server has responded to them.</p>
<p>As you can see in the previous image, you have an empty network in the dev tools. An empty network happens when you open the dev tools after the website sends the calls. A refresh (F5) on the website will be enough to check the calls.</p>
<p>In the following image, you can see the requests sent from the website to the server. If you analyze the request names, you will find one of the requests called <code>sales</code>, which is the one that has sales data. You can open the call and see the result of it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258473336/5b627ea4-9b84-440d-a6e4-d3b5a99714a6.png" alt="Showing sales endpoints from the network tab in Chrome developer tools" class="image--center mx-auto" width="788" height="272" loading="lazy"></p>
<p>If you click on the call, you will see that you have the headers, cookies, responses, and so on.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258693963/69337d7d-79e1-4f16-b161-dc9401cb3c73.png" alt="Showing the headers of the API endpoint from Chrome dev tools" class="image--center mx-auto" width="788" height="454" loading="lazy"></p>
<p>These tabs will help you understand the result of the call, the origin, and the request and response of the request. If you go to the response tab, you can see the sales data as JSON, which is what the website uses.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258724075/6b1610e9-9919-4de1-beac-c57aa32d5768.png" alt="Showing the response of the API endpoint from network developer tools" class="image--center mx-auto" width="788" height="454" loading="lazy"></p>
<p>Right now, since you have this call, you can use it in the browser to get the result. To do this, you need to use the fetch function from JavaScript. This approach will help you see the function's result and how it works.</p>
<p>A simple way to do that is to click on the call, then go to “copy,” and choose “copy as fetch”. In this case, “fetch it” means copying the request to reuse as a fetch call in JavaScript, with all of the headers and body included in the copied text.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258767702/46715fa0-1abc-4d57-8912-3a84032b4d31.png" alt="The steps to copy the API endpoint as fetch" class="image--center mx-auto" width="448" height="650" loading="lazy"></p>
<p>Here is the code of the fetch:</p>
<pre><code class="lang-js"><span class="hljs-keyword">let</span> fetchResult = fetch(<span class="hljs-string">"http://localhost:3000/api/sales"</span>, {
<span class="hljs-string">"headers"</span>: {
<span class="hljs-string">"accept"</span>: <span class="hljs-string">"*/*"</span>,
<span class="hljs-string">"accept-language"</span>: <span class="hljs-string">"en,tr-TR;q=0.9,tr;q=0.8,en-US;q=0.7,ar;q=0.6,it;q=0.5"</span>,
<span class="hljs-string">"sec-ch-ua"</span>: <span class="hljs-string">"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\""</span>,
<span class="hljs-string">"sec-ch-ua-mobile"</span>: <span class="hljs-string">"?0"</span>,
<span class="hljs-string">"sec-ch-ua-platform"</span>: <span class="hljs-string">"\"Windows\""</span>,
<span class="hljs-string">"sec-fetch-dest"</span>: <span class="hljs-string">"empty"</span>,
<span class="hljs-string">"sec-fetch-mode"</span>: <span class="hljs-string">"cors"</span>,
<span class="hljs-string">"sec-fetch-site"</span>: <span class="hljs-string">"same-origin"</span>
},
<span class="hljs-string">"referrer"</span>: <span class="hljs-string">"http://localhost:3000/dashboard"</span>,
<span class="hljs-string">"referrerPolicy"</span>: <span class="hljs-string">"strict-origin-when-cross-origin"</span>,
<span class="hljs-string">"body"</span>: <span class="hljs-literal">null</span>,
<span class="hljs-string">"method"</span>: <span class="hljs-string">"GET"</span>,
<span class="hljs-string">"mode"</span>: <span class="hljs-string">"cors"</span>,
<span class="hljs-string">"credentials"</span>: <span class="hljs-string">"include"</span>
})
</code></pre>
<p>You can consume this call by using this code:</p>
<pre><code class="lang-js">fetchResult.then(<span class="hljs-function"><span class="hljs-params">res</span> =&gt;</span> res.json()).then(<span class="hljs-built_in">console</span>.log)
</code></pre>
<p>Here is the result of the fetch:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731258819878/45eebd69-3af1-4474-b221-65c527ead3eb.png" alt="The promise results in the console" class="image--center mx-auto" width="746" height="204" loading="lazy"></p>
<p>As you can see, you were able use the API to get the sales results and explore them. You can now use this data anywhere on the current website and fetch the sales API programmatically.</p>
<p>Doing this fetch through the browser's developer tools will assume that you are doing it in the website's name. This will add extra headers to the API request, like the current website hostname in the headers and current cookies attached to the API request.</p>
<p>But what if you're going to use the API outside the same website? You might wish to use the API on your website or a server. By using it on your website, I mean getting the sales data and showing it as a widget on your website, or even getting these data to store them on your server and process them to do data mining.</p>
<p>Using the data outside the host website would require a different hostname to be attached to the headers and other cookies connected to the API request. For these cases, you will need to use Postman, an HTTP request-testing software that helps you test, explore, and read API request data.</p>
<h3 id="heading-how-to-use-the-api-on-your-website">How to Use the API on Your Website</h3>
<p>Since you got the API endpoint from the previous section, which was the following:</p>
<pre><code class="lang-bash">http://localhost:3000/api/sales
</code></pre>
<p>You might expect to use this URL and fetch the data you used before – and have it work immediately in Postman and be able to use it on your website. But it doesn’t work like this, since this fetch request does not contain any data about the authorization.</p>
<p>You can try it in Postman yourself to see the error. Postman provides two ways to write the fetch URL: the first one is to write the call in the Postman UI, and the other one is to import the API call to try it (as you can see in the image below).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731260011131/b41277da-4b01-4212-a577-27491948ec94.png" alt="Screenshot from Postman showing where to paste the url" class="image--center mx-auto" width="1914" height="473" loading="lazy"></p>
<p>To import the fetch request to Postman, first you need to click on the call from the network tab and copy it as:</p>
<ol>
<li><p>cURL (Bash), then paste it to Postman. Copying the call as cURL (Bash) will allow you to get the headers and all related data from the website, like cookies, and so on.</p>
</li>
<li><p>URL only, which will have the URL without having extra data.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731260138355/3411bf8b-23b8-4a16-88fa-bfb4601458c0.png" alt="Screenshot from devtools showing how to copy as curl" class="image--center mx-auto" width="451" height="554" loading="lazy"></p>
<p>For this article, you will copy it as a URL and paste it into Postman. You'll do that to get a clean API call without the connected headers and cookies. Then, you'll click on the send button in Postman to make the request and get the result from the API.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731260209476/b8c1633b-299a-4cca-afc5-1dee704be170.png" alt="Postman interface showing where to paste the URL" class="image--center mx-auto" width="1374" height="332" loading="lazy"></p>
<p>When you click on send, you will see that you've gotten an unauthorized message from the API body and a status code of 200. The unauthorized message happened because you did not log into the website from Postman, and the status code is due to the API design. Some APIs might return a status code 401: unauthorized, which you might encounter on other websites.</p>
<p>Being unauthorized means that you are not logged in and do not have permission to use this specific API. Some APIs are public, which you can use without any API key or extra details. Other APIs need authorization in terms of using a username and password or even a key provided by the API provider.</p>
<p>In this example, we are using a private API which needs to be authorized. Here you are getting the data that you are only allowed to access.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731260251132/aeb7c25c-6c39-475b-8e36-f265f8c7462c.png" alt="Postman screenshot showing the API body and the status" class="image--center mx-auto" width="1488" height="595" loading="lazy"></p>
<h3 id="heading-how-to-get-the-authorization-and-explore-it-through-the-website">How to Get the Authorization and Explore it Through the Website</h3>
<p>Based on the previous section, when you tried the API call, you got a message telling you that you are unauthorized. The request needs authorization.</p>
<p>So you need to establish an assumption about authorization by saying that you can get the authorization information from the login page in the demo website. For the assumption here, you might think about how the website is using the username and password to record the session. Usually websites record the session through cookies.</p>
<p>After getting logged in, you will be able use the API call to get the data you want.</p>
<p>To try that, you can go through the following steps:</p>
<p>To figure out what the website is doing to get authorized, you can either:</p>
<ul>
<li><p>Try copying the URL as bash and seeing what extra cookies and options you get</p>
</li>
<li><p>Try logging in, getting the data from the login, and sending it to the protected call</p>
</li>
</ul>
<h4 id="heading-checking-the-login-method">Checking the login method</h4>
<p>You need to figure out how the website login function works and what data is being sent to the server to verify the API request and get authorized. So, you should check the login page and analyze the requests.</p>
<p>Here are the steps:</p>
<ul>
<li><p>Go to the login page</p>
</li>
<li><p>Open the dev tools</p>
</li>
<li><p>Go to the network tab</p>
</li>
<li><p>Click on the login button</p>
</li>
</ul>
<p>When you click on login, enable the preserve log option to preserve logs when you browse through different pages or the website redirects you.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731263927757/4cfb87f3-4688-403e-af6d-184a2c2f4aa9.png" alt="Showing the network tab from developer tools focusing on preserving log and the login endpoint" class="image--center mx-auto" width="787" height="362" loading="lazy"></p>
<p>As you can see from the image above, you got a login call from the website. You need to explore the call and see what results you get from it. Here is the explanation of the data from the response:</p>
<p>The response:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731263967453/56dadf64-14dc-48bc-a81b-b04cafca95e4.png" alt="Showing the response tab from the network Chrome developer tools" class="image--center mx-auto" width="719" height="275" loading="lazy"></p>
<p>In the above image, you can see that you got a message that just says “ok”, which does not provide much detail. Right now, you need to check the headers and the cookies to see what the server sent and if you can use the server headers for authentication.</p>
<p>If you check the headers, you can find a response header called set-cookie, which is responsible for setting a cookie on your machine. This one has a <code>loggedin=true</code> value, indicating a log-in flag that the website could use.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731264427158/4f084e2a-b84a-4d5c-95b6-35ebfc725ab6.png" alt="Showing the set-cookie response header from headers" class="image--center mx-auto" width="771" height="403" loading="lazy"></p>
<p>You will see the same value when you go to the cookies tab.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731264453607/8ad16508-b19d-4b69-89ba-6e796d7f0120.png" alt="Showing the cookies header, focusing on the logged value" class="image--center mx-auto" width="786" height="403" loading="lazy"></p>
<p>Here, you might think that having a cookie sent with the “sales” request header could authorize the request. To double-check that, you can open the sales request from the dev tools and see what extra details the request headers have, from headers or cookies:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731264041695/ca811292-ae3a-4bb1-a33c-4a046b19ff4a.png" alt="Showing the headers, then request headers and cookies" class="image--center mx-auto" width="749" height="413" loading="lazy"></p>
<p>If you go to the cookies tab, you will notice the request did send the same cookies:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731263995185/bcc7f3ee-e510-42cb-817d-94a8f31472f6.png" alt="Showing the cookies tab with request cookies and focusing on the provided cookie" class="image--center mx-auto" width="787" height="362" loading="lazy"></p>
<p>To ensure the cookies are the reason, you can return to the Postman call and add a cookie to test the call.</p>
<p>You need to do the following:</p>
<ul>
<li><p>Open the headers tab</p>
</li>
<li><p>Add cookie as header</p>
</li>
<li><p>Send the request</p>
</li>
<li><p>Check the result</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731264213696/85b535b9-d488-4bff-95f3-8fecb5093f1e.png" alt="Postman screenshot focusing on headers, showing the cookie, status, and the data" class="image--center mx-auto" width="898" height="781" loading="lazy"></p>
<p>As you can see, you got a result of the data, which means that the server authorized the request, and you can access the data. Getting the data confirms the hypothesis you set in the beginning: the endpoint needs authentication.</p>
<h4 id="heading-checking-the-sales-with-curl-bash">Checking the sales with cURL (Bash)</h4>
<p>A more straightforward and more accessible way to do this would be by copying the request as cURL (Bash), which brings all the options to Postman. Then, you need to analyze the options and see what headers the server sent for authorization.</p>
<p>You can check out the following image, which has the URL pasted as cURL (Bash):</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1731264264606/f45c11c6-7121-4702-a57e-9435252d7245.png" alt="Postman screenshot showing the headers and focusing on the cookie key." class="image--center mx-auto" width="752" height="591" loading="lazy"></p>
<p>In the image, you can see that you have added 12 extra headers, and you can check them and analyze them. Sometimes, you might find an authorization header. Other times, you might have other token headers that you need to consider.</p>
<p>When you notice the header responsible for authorization, you need to go back to the website and analyze it from the beginning to check which endpoint provides the related authorization. You did it the hard way at the beginning to enable you to understand how to authenticate if the cookies were a token or something that would be challenging to figure out.</p>
<p>As you will see in the next section, website authentication is getting more complicated daily, and you must be ready to try all of the methods.</p>
<h3 id="heading-next-steps-authorization-and-authentication">Next Steps: Authorization and Authentication</h3>
<p>Authentication and security are significant issues. As you noticed on the website, you had to use the cookies to show authentication, which would be valid for some websites.</p>
<p>Other websites might have more advanced encrypted methods to authenticate and authorize. For those situations, basic knowledge and curiosity will help you explore and use the APIs from the website.</p>
<p>Some websites use the OAuth standards to authorize, saving a token on the website to send requests. As you move forward and reverse engineer more websites, you will notice the different patterns and will be able to understand them and become better at this work.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>This article was for educational purposes, which is why we used a clean website to help you see things quickly.</p>
<p>In real-world examples, things are complicated, and you'll need to explore them more. But the main principles stay similar for all situations: one endpoint brings the authorization/authentication data and another that brings the related data.</p>
<p>Reverse engineering is not easy and requires a fair bit of patience, dedication, and persistence. As you can see, understanding the website takes a lot of time. Not all websites have clean API calls, and some have the calls mixed with a different number of files needed for the website, such as CSS scripts or even images. All you need is to be patient and try to think outside the box.</p>
<p>If you like this article, subscribe to my <a target="_blank" href="https://hadithtechen.substack.com/">newsletter</a> and follow me on <a target="_blank" href="https://www.freecodecamp.org/news/p/f2a5144e-e6e3-479d-a672-caf80e7431d2/twitter.com/a0m0rajab">Twitter</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
